diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d060ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gradle +.idea +**/build +out +*.iml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b1f57c --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +*.tmp +*.log +*.gz +*.zip +*.patch +.gradle-local/ +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Kotlin ### +.kotlin + +### Local env ### +.env +/.env.example +/pcp.zip +/promt/Промт1.txt +/pcp_zip.zip +/pcp-chatgpt-context*.zip +/deploy/local-bundle/build/ +/deploy/local-bundle/pcp-local-bundle-*.tar.gz diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e5389b1 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,157 @@ +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: + - services/pcp-ballistics-service/**/* + - services/pcp-srpring-boot-admin-server/**/* + - services/spring-cloud-config-server/**/* + - services/pcp-complex-mission-service/**/* + - services/tle-monitoring-service/**/* + - services/pcp-mission-planing-service/**/* + - services/pcp-request-service/**/* + - services/pcp-stations-service/**/* + - services/pcp-satellite-catalog-service/**/* + - services/slots-service/**/* + - services/pcp-ui-service/**/* + - services/pcp-coverage-scheme-service/**/* + - services/pcp-tgu-service/**/* + - services/pcp-dynamic-plan-service/**/* + - libs/pcp-types-lib/**/* + - libs/ballistics-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - gradle.properties + - gradle/**/* + - .gitlab-ci.yml + - .gitlab/ci/**/* + - helm/pcp-ballistics-service/**/* + - helm/pcp-spring-boot-admin-server/**/* + - helm/spring-cloud-config-server/**/* + - helm/pcp-complex-mission-service/**/* + - helm/tle-monitoring-service/**/* + - helm/pcp-mission-planing-service/**/* + - helm/pcp-request-service/**/* + - helm/pcp-stations-service/**/* + - helm/pcp-satellite-catalog-service/**/* + - helm/slots-service/**/* + - helm/pcp-ui-service/**/* + - helm/pcp-coverage-scheme-service/**/* + - helm/pcp-tgu-service/**/* + - helm/pcp-dynamic-plan-service/**/* + - if: '$CI_PIPELINE_SOURCE == "push"' + changes: + - .gitlab-ci.yml + - .gitlab/ci/**/* + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"' + changes: + - services/pcp-ballistics-service/**/* + - services/pcp-srpring-boot-admin-server/**/* + - services/spring-cloud-config-server/**/* + - services/pcp-complex-mission-service/**/* + - services/tle-monitoring-service/**/* + - services/pcp-mission-planing-service/**/* + - services/pcp-request-service/**/* + - services/pcp-stations-service/**/* + - services/pcp-satellite-catalog-service/**/* + - services/slots-service/**/* + - services/pcp-ui-service/**/* + - services/pcp-coverage-scheme-service/**/* + - services/pcp-tgu-service/**/* + - services/pcp-dynamic-plan-service/**/* + - libs/pcp-types-lib/**/* + - libs/ballistics-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - gradle.properties + - gradle/**/* + - .gitlab-ci.yml + - .gitlab/ci/**/* + - helm/pcp-ballistics-service/**/* + - helm/pcp-spring-boot-admin-server/**/* + - helm/spring-cloud-config-server/**/* + - helm/pcp-complex-mission-service/**/* + - helm/tle-monitoring-service/**/* + - helm/pcp-mission-planing-service/**/* + - helm/pcp-request-service/**/* + - helm/pcp-stations-service/**/* + - helm/pcp-satellite-catalog-service/**/* + - helm/slots-service/**/* + - helm/pcp-ui-service/**/* + - helm/pcp-coverage-scheme-service/**/* + - helm/pcp-tgu-service/**/* + - helm/pcp-dynamic-plan-service/**/* + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master"' + changes: + - services/pcp-ballistics-service/**/* + - services/pcp-srpring-boot-admin-server/**/* + - services/spring-cloud-config-server/**/* + - services/pcp-complex-mission-service/**/* + - services/tle-monitoring-service/**/* + - services/pcp-mission-planing-service/**/* + - services/pcp-request-service/**/* + - services/pcp-stations-service/**/* + - services/pcp-satellite-catalog-service/**/* + - services/slots-service/**/* + - services/pcp-ui-service/**/* + - services/pcp-coverage-scheme-service/**/* + - services/pcp-tgu-service/**/* + - services/pcp-dynamic-plan-service/**/* + - libs/pcp-types-lib/**/* + - libs/ballistics-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - gradle.properties + - gradle/**/* + - .gitlab-ci.yml + - .gitlab/ci/**/* + - helm/pcp-ballistics-service/**/* + - helm/pcp-spring-boot-admin-server/**/* + - helm/spring-cloud-config-server/**/* + - helm/pcp-complex-mission-service/**/* + - helm/tle-monitoring-service/**/* + - helm/pcp-mission-planing-service/**/* + - helm/pcp-request-service/**/* + - helm/pcp-stations-service/**/* + - helm/pcp-satellite-catalog-service/**/* + - helm/slots-service/**/* + - helm/pcp-ui-service/**/* + - helm/pcp-coverage-scheme-service/**/* + - helm/pcp-tgu-service/**/* + - helm/pcp-dynamic-plan-service/**/* + - when: never + +default: + tags: + - nstart + +stages: + - test + - build + - package + - deploy + +ci:config: + stage: test + image: alpine:3.20 + rules: + - changes: + - .gitlab-ci.yml + - .gitlab/ci/**/* + - when: never + script: + - echo "GitLab CI configuration changed" + +include: + - local: .gitlab/ci/pcp-ballistics-service.yml + - local: .gitlab/ci/pcp-spring-boot-admin-server.yml + - local: .gitlab/ci/spring-cloud-config-server.yml + - local: .gitlab/ci/pcp-complex-mission-service-tle.yml + - local: .gitlab/ci/pcp-mission-planing-service.yml + - local: .gitlab/ci/pcp-request-service.yml + - local: .gitlab/ci/pcp-stations-service.yml + - local: .gitlab/ci/pcp-satellite-catalog-service.yml + - local: .gitlab/ci/slots-service.yml + - local: .gitlab/ci/pcp-ui-service.yml + - local: .gitlab/ci/pcp-coverage-scheme-service.yml + - local: .gitlab/ci/pcp-tgu-service.yml + - local: .gitlab/ci/pcp-dynamic-plan-service.yml diff --git a/.gitlab/ci/pcp-ballistics-service.yml b/.gitlab/ci/pcp-ballistics-service.yml new file mode 100644 index 0000000..7c337a2 --- /dev/null +++ b/.gitlab/ci/pcp-ballistics-service.yml @@ -0,0 +1,267 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + BALLISTICS_SERVICE_DIR: "services/pcp-ballistics-service" + + # При необходимости переопредели путь к chart через CI/CD variable + BALLISTICS_HELM_CHART_DIR: "helm/pcp-ballistics-service" + + # Имя релиза в k8s + BALLISTICS_HELM_RELEASE: "pcp-ballistics-service" + + # Полный путь до image во внутреннем registry + BALLISTICS_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-ballistics-service" + + # Окружения / namespace + BALLISTICS_K8S_NAMESPACE_DEV: "pcp-dev" + BALLISTICS_K8S_NAMESPACE_MASTER: "pcp-master" + + + BALLISTICS_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" +.ballistics:changes: &ballistics_changes + - services/pcp-ballistics-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-ballistics-service.* + - helm/pcp-ballistics-service/**/* + - deploy/**/* + +.ballistics:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *ballistics_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *ballistics_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *ballistics_changes + - when: never + +.ballistics:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *ballistics_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *ballistics_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *ballistics_changes + - when: never + +.ballistics:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *ballistics_changes + when: manual + - when: never + +.ballistics:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *ballistics_changes + when: manual + - when: never + +.ballistics:gradle-cache: + cache: + key: "gradle-ballistics-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.ballistics:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.ballistics:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +ballistics:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + services: + - name: repo.nstart.cloud/postgis/postgis:16-3.4 + alias: postgres + variables: + POSTGRES_DB: "pcp_ballistics" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "password" + SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/pcp_ballistics" + SPRING_DATASOURCE_USERNAME: "postgres" + SPRING_DATASOURCE_PASSWORD: "password" + extends: + - .ballistics:rules:test + - .ballistics:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-ballistics-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-ballistics-service/build/test-results/test/*.xml + paths: + - services/pcp-ballistics-service/build/reports/tests/ + expire_in: 7 days + +ballistics:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .ballistics:rules:build-and-image + - .ballistics:gradle-cache + needs: + - job: ballistics:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-ballistics-service:bootJar + artifacts: + when: always + paths: + - services/pcp-ballistics-service/build/libs/*.jar + expire_in: 7 days + +ballistics:package: + stage: package + extends: + - .ballistics:rules:build-and-image + - .ballistics:docker-build + needs: + - job: ballistics:build + artifacts: true + script: + - 'echo "BALLISTICS_IMAGE_REPOSITORY=$BALLISTICS_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$BALLISTICS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$BALLISTICS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$BALLISTICS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$BALLISTICS_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$BALLISTICS_IMAGE_REPOSITORY:$BALLISTICS_IMAGE_TAG" docker-context + - docker push "$BALLISTICS_IMAGE_REPOSITORY:$BALLISTICS_IMAGE_TAG" + - printf "BALLISTICS_IMAGE=%s\n" "$BALLISTICS_IMAGE_REPOSITORY:$BALLISTICS_IMAGE_TAG" > ballistics-image.env + artifacts: + reports: + dotenv: ballistics-image.env + expire_in: 7 days + +ballistics:deploy:dev: + stage: deploy + extends: + - .ballistics:rules:deploy-dev + - .ballistics:helm-deploy + needs: + - job: ballistics:package + artifacts: true + environment: + name: dev/pcp-ballistics-service + resource_group: pcp-ballistics-service-dev + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${BALLISTICS_IMAGE%:*}"' + - 'export IMAGE_TAG="${BALLISTICS_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$BALLISTICS_HELM_RELEASE" + "$BALLISTICS_HELM_CHART_DIR" + --namespace "$BALLISTICS_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$BALLISTICS_HELM_CHART_DIR/values.yaml" + -f "$BALLISTICS_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +ballistics:deploy:master: + stage: deploy + extends: + - .ballistics:rules:deploy-master + - .ballistics:helm-deploy + needs: + - job: ballistics:package + artifacts: true + environment: + name: master/pcp-ballistics-service + resource_group: pcp-ballistics-service-master + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${BALLISTICS_IMAGE%:*}"' + - 'export IMAGE_TAG="${BALLISTICS_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$BALLISTICS_HELM_RELEASE" + "$BALLISTICS_HELM_CHART_DIR" + --namespace "$BALLISTICS_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$BALLISTICS_HELM_CHART_DIR/values.yaml" + -f "$BALLISTICS_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-complex-mission-service-tle.yml b/.gitlab/ci/pcp-complex-mission-service-tle.yml new file mode 100644 index 0000000..94e3b81 --- /dev/null +++ b/.gitlab/ci/pcp-complex-mission-service-tle.yml @@ -0,0 +1,471 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + COMPLEX_MISSION_SERVICE_DIR: "services/pcp-complex-mission-service" + COMPLEX_MISSION_SERVICE_HELM_CHART_DIR: "helm/pcp-complex-mission-service" + COMPLEX_MISSION_SERVICE_HELM_RELEASE: "pcp-complex-mission-service" + COMPLEX_MISSION_SERVICE_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-complex-mission-service" + COMPLEX_MISSION_SERVICE_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + COMPLEX_MISSION_SERVICE_K8S_NAMESPACE_DEV: "pcp-dev" + COMPLEX_MISSION_SERVICE_K8S_NAMESPACE_MASTER: "pcp-master" + + TLE_MONITORING_SERVICE_DIR: "services/tle-monitoring-service" + TLE_MONITORING_HELM_CHART_DIR: "helm/tle-monitoring-service" + TLE_MONITORING_HELM_RELEASE: "tle-monitoring-service" + TLE_MONITORING_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/tle-monitoring-service" + TLE_MONITORING_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + TLE_MONITORING_K8S_NAMESPACE_DEV: "pcp-dev" + TLE_MONITORING_K8S_NAMESPACE_MASTER: "pcp-master" + +.complex_mission_service_changes: &complex_mission_service_changes + - services/pcp-complex-mission-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-complex-mission-service-tle.yml + - helm/pcp-complex-mission-service/**/* + - deploy/**/* + +.tle_monitoring_changes: &tle_monitoring_changes + - services/tle-monitoring-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-complex-mission-service-tle.yml + - helm/tle-monitoring-service/**/* + - deploy/**/* + +.complex-mission-service:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *complex_mission_service_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *complex_mission_service_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *complex_mission_service_changes + - when: never + +.complex-mission-service:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *complex_mission_service_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *complex_mission_service_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *complex_mission_service_changes + - when: never + +.complex-mission-service:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *complex_mission_service_changes + when: manual + - when: never + +.complex-mission-service:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *complex_mission_service_changes + when: manual + - when: never + +.tle-monitoring:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *tle_monitoring_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *tle_monitoring_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *tle_monitoring_changes + - when: never + +.tle-monitoring:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *tle_monitoring_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *tle_monitoring_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *tle_monitoring_changes + - when: never + +.tle-monitoring:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *tle_monitoring_changes + when: manual + - when: never + +.tle-monitoring:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *tle_monitoring_changes + when: manual + - when: never + +.pcp-gradle-cache: + cache: + key: "gradle-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.pcp-docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.pcp-helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube ~/.kube + - helm version + after_script: + - rm -f /root/.kube/config ~/.kube/config + +complex-mission-service:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .complex-mission-service:rules:test + - .pcp-gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-complex-mission-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-complex-mission-service/build/test-results/test/*.xml + paths: + - services/pcp-complex-mission-service/build/reports/tests/ + expire_in: 7 days + +complex-mission-service:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .complex-mission-service:rules:build-and-image + - .pcp-gradle-cache + needs: + - job: complex-mission-service:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-complex-mission-service:bootJar + artifacts: + when: always + paths: + - services/pcp-complex-mission-service/build/libs/*.jar + expire_in: 7 days + +complex-mission-service:package: + stage: package + extends: + - .complex-mission-service:rules:build-and-image + - .pcp-docker-build + needs: + - job: complex-mission-service:build + artifacts: true + script: + - 'echo "COMPLEX_MISSION_SERVICE_IMAGE_REPOSITORY=$COMPLEX_MISSION_SERVICE_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$COMPLEX_MISSION_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$COMPLEX_MISSION_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$COMPLEX_MISSION_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$COMPLEX_MISSION_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$COMPLEX_MISSION_SERVICE_IMAGE_REPOSITORY:$COMPLEX_MISSION_SERVICE_IMAGE_TAG" docker-context + - docker push "$COMPLEX_MISSION_SERVICE_IMAGE_REPOSITORY:$COMPLEX_MISSION_SERVICE_IMAGE_TAG" + - printf "COMPLEX_MISSION_SERVICE_IMAGE=%s\n" "$COMPLEX_MISSION_SERVICE_IMAGE_REPOSITORY:$COMPLEX_MISSION_SERVICE_IMAGE_TAG" > complex-mission-service-image.env + artifacts: + reports: + dotenv: complex-mission-service-image.env + expire_in: 7 days + +complex-mission-service:deploy:dev: + stage: deploy + extends: + - .complex-mission-service:rules:deploy-dev + - .pcp-helm-deploy + needs: + - job: complex-mission-service:package + artifacts: true + environment: + name: dev/pcp-complex-mission-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${COMPLEX_MISSION_SERVICE_IMAGE%:*}"' + - 'export IMAGE_TAG="${COMPLEX_MISSION_SERVICE_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$COMPLEX_MISSION_SERVICE_HELM_RELEASE" + "$COMPLEX_MISSION_SERVICE_HELM_CHART_DIR" + --namespace "$COMPLEX_MISSION_SERVICE_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$COMPLEX_MISSION_SERVICE_HELM_CHART_DIR/values.yaml" + -f "$COMPLEX_MISSION_SERVICE_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +complex-mission-service:deploy:master: + stage: deploy + extends: + - .complex-mission-service:rules:deploy-master + - .pcp-helm-deploy + needs: + - job: complex-mission-service:package + artifacts: true + environment: + name: master/pcp-complex-mission-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${COMPLEX_MISSION_SERVICE_IMAGE%:*}"' + - 'export IMAGE_TAG="${COMPLEX_MISSION_SERVICE_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$COMPLEX_MISSION_SERVICE_HELM_RELEASE" + "$COMPLEX_MISSION_SERVICE_HELM_CHART_DIR" + --namespace "$COMPLEX_MISSION_SERVICE_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$COMPLEX_MISSION_SERVICE_HELM_CHART_DIR/values.yaml" + -f "$COMPLEX_MISSION_SERVICE_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master + +tle-monitoring:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + services: + - name: repo.nstart.cloud/postgis/postgis:16-3.4 + alias: postgres + variables: + POSTGRES_DB: "pcp_tle" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "password" + SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/pcp_tle" + SPRING_DATASOURCE_USERNAME: "postgres" + SPRING_DATASOURCE_PASSWORD: "password" + SPRING_TASK_SCHEDULING_ENABLED: "false" + extends: + - .tle-monitoring:rules:test + - .pcp-gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:tle-monitoring-service:test + artifacts: + when: always + reports: + junit: + - services/tle-monitoring-service/build/test-results/test/*.xml + paths: + - services/tle-monitoring-service/build/reports/tests/ + expire_in: 7 days + +tle-monitoring:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .tle-monitoring:rules:build-and-image + - .pcp-gradle-cache + needs: + - job: tle-monitoring:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:tle-monitoring-service:bootJar + artifacts: + when: always + paths: + - services/tle-monitoring-service/build/libs/*.jar + expire_in: 7 days + +tle-monitoring:package: + stage: package + extends: + - .tle-monitoring:rules:build-and-image + - .pcp-docker-build + needs: + - job: tle-monitoring:build + artifacts: true + script: + - 'echo "TLE_MONITORING_IMAGE_REPOSITORY=$TLE_MONITORING_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$TLE_MONITORING_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$TLE_MONITORING_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$TLE_MONITORING_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$TLE_MONITORING_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$TLE_MONITORING_IMAGE_REPOSITORY:$TLE_MONITORING_IMAGE_TAG" docker-context + - docker push "$TLE_MONITORING_IMAGE_REPOSITORY:$TLE_MONITORING_IMAGE_TAG" + - printf "TLE_MONITORING_IMAGE=%s\n" "$TLE_MONITORING_IMAGE_REPOSITORY:$TLE_MONITORING_IMAGE_TAG" > tle-monitoring-image.env + artifacts: + reports: + dotenv: tle-monitoring-image.env + expire_in: 7 days + +tle-monitoring:deploy:dev: + stage: deploy + extends: + - .tle-monitoring:rules:deploy-dev + - .pcp-helm-deploy + needs: + - job: tle-monitoring:package + artifacts: true + environment: + name: dev/tle-monitoring-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${TLE_MONITORING_IMAGE%:*}"' + - 'export IMAGE_TAG="${TLE_MONITORING_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$TLE_MONITORING_HELM_RELEASE" + "$TLE_MONITORING_HELM_CHART_DIR" + --namespace "$TLE_MONITORING_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 15m + -f "$TLE_MONITORING_HELM_CHART_DIR/values.yaml" + -f "$TLE_MONITORING_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +tle-monitoring:deploy:master: + stage: deploy + extends: + - .tle-monitoring:rules:deploy-master + - .pcp-helm-deploy + needs: + - job: tle-monitoring:package + artifacts: true + environment: + name: master/tle-monitoring-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${TLE_MONITORING_IMAGE%:*}"' + - 'export IMAGE_TAG="${TLE_MONITORING_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$TLE_MONITORING_HELM_RELEASE" + "$TLE_MONITORING_HELM_CHART_DIR" + --namespace "$TLE_MONITORING_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 15m + -f "$TLE_MONITORING_HELM_CHART_DIR/values.yaml" + -f "$TLE_MONITORING_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-coverage-scheme-service.yml b/.gitlab/ci/pcp-coverage-scheme-service.yml new file mode 100644 index 0000000..92abf30 --- /dev/null +++ b/.gitlab/ci/pcp-coverage-scheme-service.yml @@ -0,0 +1,244 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + COVERAGE_SCHEME_SERVICE_DIR: "services/pcp-coverage-scheme-service" + COVERAGE_SCHEME_HELM_CHART_DIR: "helm/pcp-coverage-scheme-service" + COVERAGE_SCHEME_HELM_RELEASE: "pcp-coverage-scheme-service" + COVERAGE_SCHEME_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-coverage-scheme-service" + COVERAGE_SCHEME_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + COVERAGE_SCHEME_K8S_NAMESPACE_DEV: "pcp-dev" + COVERAGE_SCHEME_K8S_NAMESPACE_MASTER: "pcp-master" + +.coverage_scheme_changes: &coverage_scheme_changes + - services/pcp-coverage-scheme-service/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - gradle.properties + - .gitlab/ci/pcp-coverage-scheme-service.yml + - helm/pcp-coverage-scheme-service/**/* + - deploy/**/* + +.coverage_scheme:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *coverage_scheme_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *coverage_scheme_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *coverage_scheme_changes + - when: never + +.coverage_scheme:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *coverage_scheme_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *coverage_scheme_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *coverage_scheme_changes + - when: never + +.coverage_scheme:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *coverage_scheme_changes + when: manual + - when: never + +.coverage_scheme:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *coverage_scheme_changes + when: manual + - when: never + +.coverage_scheme:gradle-cache: + cache: + key: "gradle-coverage-scheme-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.coverage_scheme:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.coverage_scheme:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +coverage-scheme:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .coverage_scheme:rules:test + - .coverage_scheme:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-coverage-scheme-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-coverage-scheme-service/build/test-results/test/*.xml + paths: + - services/pcp-coverage-scheme-service/build/reports/tests/ + expire_in: 7 days + +coverage-scheme:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .coverage_scheme:rules:build-and-image + - .coverage_scheme:gradle-cache + needs: + - job: coverage-scheme:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-coverage-scheme-service:bootJar + artifacts: + when: always + paths: + - services/pcp-coverage-scheme-service/build/libs/*.jar + expire_in: 7 days + +coverage-scheme:package: + stage: package + extends: + - .coverage_scheme:rules:build-and-image + - .coverage_scheme:docker-build + needs: + - job: coverage-scheme:build + artifacts: true + script: + - 'echo "COVERAGE_SCHEME_IMAGE_REPOSITORY=$COVERAGE_SCHEME_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$COVERAGE_SCHEME_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$COVERAGE_SCHEME_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$COVERAGE_SCHEME_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$COVERAGE_SCHEME_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$COVERAGE_SCHEME_IMAGE_REPOSITORY:$COVERAGE_SCHEME_IMAGE_TAG" docker-context + - docker push "$COVERAGE_SCHEME_IMAGE_REPOSITORY:$COVERAGE_SCHEME_IMAGE_TAG" + - printf "COVERAGE_SCHEME_IMAGE=%s\n" "$COVERAGE_SCHEME_IMAGE_REPOSITORY:$COVERAGE_SCHEME_IMAGE_TAG" > coverage-scheme-image.env + artifacts: + reports: + dotenv: coverage-scheme-image.env + expire_in: 7 days + +coverage-scheme:deploy:dev: + stage: deploy + extends: + - .coverage_scheme:rules:deploy-dev + - .coverage_scheme:helm-deploy + needs: + - job: coverage-scheme:package + artifacts: true + environment: + name: dev/pcp-coverage-scheme-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${COVERAGE_SCHEME_IMAGE%:*}"' + - 'export IMAGE_TAG="${COVERAGE_SCHEME_IMAGE##*:}"' + - > + helm upgrade --install "$COVERAGE_SCHEME_HELM_RELEASE" + "$COVERAGE_SCHEME_HELM_CHART_DIR" + --namespace "$COVERAGE_SCHEME_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$COVERAGE_SCHEME_HELM_CHART_DIR/values.yaml" + -f "$COVERAGE_SCHEME_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +coverage-scheme:deploy:master: + stage: deploy + extends: + - .coverage_scheme:rules:deploy-master + - .coverage_scheme:helm-deploy + needs: + - job: coverage-scheme:package + artifacts: true + environment: + name: master/pcp-coverage-scheme-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${COVERAGE_SCHEME_IMAGE%:*}"' + - 'export IMAGE_TAG="${COVERAGE_SCHEME_IMAGE##*:}"' + - > + helm upgrade --install "$COVERAGE_SCHEME_HELM_RELEASE" + "$COVERAGE_SCHEME_HELM_CHART_DIR" + --namespace "$COVERAGE_SCHEME_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$COVERAGE_SCHEME_HELM_CHART_DIR/values.yaml" + -f "$COVERAGE_SCHEME_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-dynamic-plan-service.yml b/.gitlab/ci/pcp-dynamic-plan-service.yml new file mode 100644 index 0000000..27b6c14 --- /dev/null +++ b/.gitlab/ci/pcp-dynamic-plan-service.yml @@ -0,0 +1,247 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + DYNAMIC_PLAN_SERVICE_DIR: "services/pcp-dynamic-plan-service" + DYNAMIC_PLAN_HELM_CHART_DIR: "helm/pcp-dynamic-plan-service" + DYNAMIC_PLAN_HELM_RELEASE: "pcp-dynamic-plan-service" + DYNAMIC_PLAN_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-dynamic-plan-service" + DYNAMIC_PLAN_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + DYNAMIC_PLAN_K8S_NAMESPACE_DEV: "pcp-dev" + DYNAMIC_PLAN_K8S_NAMESPACE_MASTER: "pcp-master" + +.dynamic_plan_changes: &dynamic_plan_changes + - services/pcp-dynamic-plan-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - gradle.properties + - .gitlab/ci/pcp-dynamic-plan-service.yml + - helm/pcp-dynamic-plan-service/**/* + - deploy/**/* + +.dynamic_plan:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *dynamic_plan_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *dynamic_plan_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *dynamic_plan_changes + - when: never + +.dynamic_plan:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *dynamic_plan_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *dynamic_plan_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *dynamic_plan_changes + - when: never + +.dynamic_plan:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *dynamic_plan_changes + when: manual + - when: never + +.dynamic_plan:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *dynamic_plan_changes + when: manual + - when: never + +.dynamic_plan:gradle-cache: + cache: + key: "gradle-dynamic-plan-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.dynamic_plan:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.dynamic_plan:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +dynamic-plan:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .dynamic_plan:rules:test + - .dynamic_plan:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-dynamic-plan-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-dynamic-plan-service/build/test-results/test/*.xml + paths: + - services/pcp-dynamic-plan-service/build/reports/tests/ + expire_in: 7 days + +dynamic-plan:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .dynamic_plan:rules:build-and-image + - .dynamic_plan:gradle-cache + needs: + - job: dynamic-plan:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-dynamic-plan-service:bootJar + artifacts: + when: always + paths: + - services/pcp-dynamic-plan-service/build/libs/*.jar + expire_in: 7 days + +dynamic-plan:package: + stage: package + extends: + - .dynamic_plan:rules:build-and-image + - .dynamic_plan:docker-build + needs: + - job: dynamic-plan:build + artifacts: true + script: + - 'echo "DYNAMIC_PLAN_IMAGE_REPOSITORY=$DYNAMIC_PLAN_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$DYNAMIC_PLAN_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$DYNAMIC_PLAN_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$DYNAMIC_PLAN_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$DYNAMIC_PLAN_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$DYNAMIC_PLAN_IMAGE_REPOSITORY:$DYNAMIC_PLAN_IMAGE_TAG" docker-context + - docker push "$DYNAMIC_PLAN_IMAGE_REPOSITORY:$DYNAMIC_PLAN_IMAGE_TAG" + - printf "DYNAMIC_PLAN_IMAGE=%s\n" "$DYNAMIC_PLAN_IMAGE_REPOSITORY:$DYNAMIC_PLAN_IMAGE_TAG" > dynamic-plan-image.env + artifacts: + reports: + dotenv: dynamic-plan-image.env + expire_in: 7 days + +dynamic-plan:deploy:dev: + stage: deploy + extends: + - .dynamic_plan:rules:deploy-dev + - .dynamic_plan:helm-deploy + needs: + - job: dynamic-plan:package + artifacts: true + environment: + name: dev/pcp-dynamic-plan-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${DYNAMIC_PLAN_IMAGE%:*}"' + - 'export IMAGE_TAG="${DYNAMIC_PLAN_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$DYNAMIC_PLAN_HELM_RELEASE" + "$DYNAMIC_PLAN_HELM_CHART_DIR" + --namespace "$DYNAMIC_PLAN_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$DYNAMIC_PLAN_HELM_CHART_DIR/values.yaml" + -f "$DYNAMIC_PLAN_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +dynamic-plan:deploy:master: + stage: deploy + extends: + - .dynamic_plan:rules:deploy-master + - .dynamic_plan:helm-deploy + needs: + - job: dynamic-plan:package + artifacts: true + environment: + name: master/pcp-dynamic-plan-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${DYNAMIC_PLAN_IMAGE%:*}"' + - 'export IMAGE_TAG="${DYNAMIC_PLAN_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$DYNAMIC_PLAN_HELM_RELEASE" + "$DYNAMIC_PLAN_HELM_CHART_DIR" + --namespace "$DYNAMIC_PLAN_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$DYNAMIC_PLAN_HELM_CHART_DIR/values.yaml" + -f "$DYNAMIC_PLAN_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-mission-planing-service.yml b/.gitlab/ci/pcp-mission-planing-service.yml new file mode 100644 index 0000000..0d00c47 --- /dev/null +++ b/.gitlab/ci/pcp-mission-planing-service.yml @@ -0,0 +1,246 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + MISSION_PLANING_SERVICE_DIR: "services/pcp-mission-planing-service" + MISSION_PLANING_HELM_CHART_DIR: "helm/pcp-mission-planing-service" + MISSION_PLANING_HELM_RELEASE: "pcp-mission-planing-service" + MISSION_PLANING_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-mission-planing-service" + MISSION_PLANING_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + MISSION_PLANING_K8S_NAMESPACE_DEV: "pcp-dev" + MISSION_PLANING_K8S_NAMESPACE_MASTER: "pcp-master" + +.mission_planing_changes: &mission_planing_changes + - services/pcp-mission-planing-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-mission-planing-service.yml + - helm/pcp-mission-planing-service/**/* + - deploy/**/* + +.mission-planing:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *mission_planing_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *mission_planing_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *mission_planing_changes + - when: never + +.mission-planing:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *mission_planing_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *mission_planing_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *mission_planing_changes + - when: never + +.mission-planing:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *mission_planing_changes + when: manual + - when: never + +.mission-planing:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *mission_planing_changes + when: manual + - when: never + +.mission-planing:gradle-cache: + cache: + key: "gradle-mission-planing-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.mission-planing:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.mission-planing:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +mission-planing:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .mission-planing:rules:test + - .mission-planing:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-mission-planing-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-mission-planing-service/build/test-results/test/*.xml + paths: + - services/pcp-mission-planing-service/build/reports/tests/ + expire_in: 7 days + +mission-planing:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .mission-planing:rules:build-and-image + - .mission-planing:gradle-cache + needs: + - job: mission-planing:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-mission-planing-service:bootJar + artifacts: + when: always + paths: + - services/pcp-mission-planing-service/build/libs/*.jar + expire_in: 7 days + +mission-planing:package: + stage: package + extends: + - .mission-planing:rules:build-and-image + - .mission-planing:docker-build + needs: + - job: mission-planing:build + artifacts: true + script: + - 'echo "MISSION_PLANING_IMAGE_REPOSITORY=$MISSION_PLANING_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$MISSION_PLANING_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$MISSION_PLANING_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$MISSION_PLANING_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$MISSION_PLANING_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$MISSION_PLANING_IMAGE_REPOSITORY:$MISSION_PLANING_IMAGE_TAG" docker-context + - docker push "$MISSION_PLANING_IMAGE_REPOSITORY:$MISSION_PLANING_IMAGE_TAG" + - printf "MISSION_PLANING_IMAGE=%s\n" "$MISSION_PLANING_IMAGE_REPOSITORY:$MISSION_PLANING_IMAGE_TAG" > mission-planing-image.env + artifacts: + reports: + dotenv: mission-planing-image.env + expire_in: 7 days + +mission-planing:deploy:dev: + stage: deploy + extends: + - .mission-planing:rules:deploy-dev + - .mission-planing:helm-deploy + needs: + - job: mission-planing:package + artifacts: true + environment: + name: dev/pcp-mission-planing-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${MISSION_PLANING_IMAGE%:*}"' + - 'export IMAGE_TAG="${MISSION_PLANING_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$MISSION_PLANING_HELM_RELEASE" + "$MISSION_PLANING_HELM_CHART_DIR" + --namespace "$MISSION_PLANING_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$MISSION_PLANING_HELM_CHART_DIR/values.yaml" + -f "$MISSION_PLANING_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +mission-planing:deploy:master: + stage: deploy + extends: + - .mission-planing:rules:deploy-master + - .mission-planing:helm-deploy + needs: + - job: mission-planing:package + artifacts: true + environment: + name: master/pcp-mission-planing-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${MISSION_PLANING_IMAGE%:*}"' + - 'export IMAGE_TAG="${MISSION_PLANING_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$MISSION_PLANING_HELM_RELEASE" + "$MISSION_PLANING_HELM_CHART_DIR" + --namespace "$MISSION_PLANING_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$MISSION_PLANING_HELM_CHART_DIR/values.yaml" + -f "$MISSION_PLANING_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-request-service.yml b/.gitlab/ci/pcp-request-service.yml new file mode 100644 index 0000000..efdfa9d --- /dev/null +++ b/.gitlab/ci/pcp-request-service.yml @@ -0,0 +1,245 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + REQUEST_SERVICE_DIR: "services/pcp-request-service" + REQUEST_HELM_CHART_DIR: "helm/pcp-request-service" + REQUEST_HELM_RELEASE: "pcp-request-service" + REQUEST_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-request-service" + REQUEST_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + REQUEST_K8S_NAMESPACE_DEV: "pcp-dev" + REQUEST_K8S_NAMESPACE_MASTER: "pcp-master" + +.request_changes: &request_changes + - services/pcp-request-service/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-request-service.yml + - helm/pcp-request-service/**/* + - deploy/**/* + +.request:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *request_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *request_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *request_changes + - when: never + +.request:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *request_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *request_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *request_changes + - when: never + +.request:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *request_changes + when: manual + - when: never + +.request:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *request_changes + when: manual + - when: never + +.request:gradle-cache: + cache: + key: "gradle-request-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.request:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.request:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +request:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .request:rules:test + - .request:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-request-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-request-service/build/test-results/test/*.xml + paths: + - services/pcp-request-service/build/reports/tests/ + expire_in: 7 days + +request:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .request:rules:build-and-image + - .request:gradle-cache + needs: + - job: request:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-request-service:bootJar + artifacts: + when: always + paths: + - services/pcp-request-service/build/libs/*.jar + expire_in: 7 days + +request:package: + stage: package + extends: + - .request:rules:build-and-image + - .request:docker-build + needs: + - job: request:build + artifacts: true + script: + - 'echo "REQUEST_IMAGE_REPOSITORY=$REQUEST_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$REQUEST_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$REQUEST_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$REQUEST_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$REQUEST_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$REQUEST_IMAGE_REPOSITORY:$REQUEST_IMAGE_TAG" docker-context + - docker push "$REQUEST_IMAGE_REPOSITORY:$REQUEST_IMAGE_TAG" + - printf "REQUEST_IMAGE=%s\n" "$REQUEST_IMAGE_REPOSITORY:$REQUEST_IMAGE_TAG" > request-image.env + artifacts: + reports: + dotenv: request-image.env + expire_in: 7 days + +request:deploy:dev: + stage: deploy + extends: + - .request:rules:deploy-dev + - .request:helm-deploy + needs: + - job: request:package + artifacts: true + environment: + name: dev/pcp-request-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${REQUEST_IMAGE%:*}"' + - 'export IMAGE_TAG="${REQUEST_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$REQUEST_HELM_RELEASE" + "$REQUEST_HELM_CHART_DIR" + --namespace "$REQUEST_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 15m + -f "$REQUEST_HELM_CHART_DIR/values.yaml" + -f "$REQUEST_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +request:deploy:master: + stage: deploy + extends: + - .request:rules:deploy-master + - .request:helm-deploy + needs: + - job: request:package + artifacts: true + environment: + name: master/pcp-request-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${REQUEST_IMAGE%:*}"' + - 'export IMAGE_TAG="${REQUEST_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$REQUEST_HELM_RELEASE" + "$REQUEST_HELM_CHART_DIR" + --namespace "$REQUEST_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 15m + -f "$REQUEST_HELM_CHART_DIR/values.yaml" + -f "$REQUEST_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-satellite-catalog-service.yml b/.gitlab/ci/pcp-satellite-catalog-service.yml new file mode 100644 index 0000000..424ccea --- /dev/null +++ b/.gitlab/ci/pcp-satellite-catalog-service.yml @@ -0,0 +1,245 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + SATELLITE_CATALOG_SERVICE_DIR: "services/pcp-satellite-catalog-service" + SATELLITE_CATALOG_HELM_CHART_DIR: "helm/pcp-satellite-catalog-service" + SATELLITE_CATALOG_HELM_RELEASE: "pcp-satellite-catalog-service" + SATELLITE_CATALOG_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-satellite-catalog-service" + SATELLITE_CATALOG_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + SATELLITE_CATALOG_K8S_NAMESPACE_DEV: "pcp-dev" + SATELLITE_CATALOG_K8S_NAMESPACE_MASTER: "pcp-master" + +.satellite_catalog_changes: &satellite_catalog_changes + - services/pcp-satellite-catalog-service/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-satellite-catalog-service.* + - helm/pcp-satellite-catalog-service/**/* + - deploy/**/* + +.satellite-catalog:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *satellite_catalog_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *satellite_catalog_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *satellite_catalog_changes + - when: never + +.satellite-catalog:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *satellite_catalog_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *satellite_catalog_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *satellite_catalog_changes + - when: never + +.satellite-catalog:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *satellite_catalog_changes + when: manual + - when: never + +.satellite-catalog:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *satellite_catalog_changes + when: manual + - when: never + +.satellite-catalog:gradle-cache: + cache: + key: "gradle-satellite-catalog-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.satellite-catalog:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.satellite-catalog:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +satellite-catalog:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .satellite-catalog:rules:test + - .satellite-catalog:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-satellite-catalog-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-satellite-catalog-service/build/test-results/test/*.xml + paths: + - services/pcp-satellite-catalog-service/build/reports/tests/ + expire_in: 7 days + +satellite-catalog:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .satellite-catalog:rules:build-and-image + - .satellite-catalog:gradle-cache + needs: + - job: satellite-catalog:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-satellite-catalog-service:bootJar + artifacts: + when: always + paths: + - services/pcp-satellite-catalog-service/build/libs/*.jar + expire_in: 7 days + +satellite-catalog:package: + stage: package + extends: + - .satellite-catalog:rules:build-and-image + - .satellite-catalog:docker-build + needs: + - job: satellite-catalog:build + artifacts: true + script: + - 'echo "SATELLITE_CATALOG_IMAGE_REPOSITORY=$SATELLITE_CATALOG_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$SATELLITE_CATALOG_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$SATELLITE_CATALOG_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$SATELLITE_CATALOG_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$SATELLITE_CATALOG_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$SATELLITE_CATALOG_IMAGE_REPOSITORY:$SATELLITE_CATALOG_IMAGE_TAG" docker-context + - docker push "$SATELLITE_CATALOG_IMAGE_REPOSITORY:$SATELLITE_CATALOG_IMAGE_TAG" + - printf "SATELLITE_CATALOG_IMAGE=%s\n" "$SATELLITE_CATALOG_IMAGE_REPOSITORY:$SATELLITE_CATALOG_IMAGE_TAG" > satellite-catalog-image.env + artifacts: + reports: + dotenv: satellite-catalog-image.env + expire_in: 7 days + +satellite-catalog:deploy:dev: + stage: deploy + extends: + - .satellite-catalog:rules:deploy-dev + - .satellite-catalog:helm-deploy + needs: + - job: satellite-catalog:package + artifacts: true + environment: + name: dev/pcp-satellite-catalog-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${SATELLITE_CATALOG_IMAGE%:*}"' + - 'export IMAGE_TAG="${SATELLITE_CATALOG_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$SATELLITE_CATALOG_HELM_RELEASE" + "$SATELLITE_CATALOG_HELM_CHART_DIR" + --namespace "$SATELLITE_CATALOG_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$SATELLITE_CATALOG_HELM_CHART_DIR/values.yaml" + -f "$SATELLITE_CATALOG_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +satellite-catalog:deploy:master: + stage: deploy + extends: + - .satellite-catalog:rules:deploy-master + - .satellite-catalog:helm-deploy + needs: + - job: satellite-catalog:package + artifacts: true + environment: + name: master/pcp-satellite-catalog-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${SATELLITE_CATALOG_IMAGE%:*}"' + - 'export IMAGE_TAG="${SATELLITE_CATALOG_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$SATELLITE_CATALOG_HELM_RELEASE" + "$SATELLITE_CATALOG_HELM_CHART_DIR" + --namespace "$SATELLITE_CATALOG_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$SATELLITE_CATALOG_HELM_CHART_DIR/values.yaml" + -f "$SATELLITE_CATALOG_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-spring-boot-admin-server.yml b/.gitlab/ci/pcp-spring-boot-admin-server.yml new file mode 100644 index 0000000..298be83 --- /dev/null +++ b/.gitlab/ci/pcp-spring-boot-admin-server.yml @@ -0,0 +1,191 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + SPRING_BOOT_ADMIN_SERVICE_DIR: "services/pcp-srpring-boot-admin-server" + SPRING_BOOT_ADMIN_HELM_CHART_DIR: "helm/pcp-spring-boot-admin-server" + SPRING_BOOT_ADMIN_HELM_RELEASE: "pcp-spring-boot-admin-server" + SPRING_BOOT_ADMIN_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-spring-boot-admin-server" + SPRING_BOOT_ADMIN_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + SPRING_BOOT_ADMIN_K8S_NAMESPACE_DEV: "pcp-dev" + +.spring_boot_admin_changes: &spring_boot_admin_changes + - services/pcp-srpring-boot-admin-server/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-spring-boot-admin-server.yml + - helm/pcp-spring-boot-admin-server/**/* + - deploy/**/* + +.spring-boot-admin:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *spring_boot_admin_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *spring_boot_admin_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *spring_boot_admin_changes + - when: never + +.spring-boot-admin:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *spring_boot_admin_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *spring_boot_admin_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *spring_boot_admin_changes + - when: never + +.spring-boot-admin:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *spring_boot_admin_changes + when: manual + - when: never + +.spring-boot-admin:gradle-cache: + cache: + key: "gradle-spring-boot-admin-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.spring-boot-admin:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.spring-boot-admin:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +spring-boot-admin:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .spring-boot-admin:rules:test + - .spring-boot-admin:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-srpring-boot-admin-server:test + artifacts: + when: always + reports: + junit: + - services/pcp-srpring-boot-admin-server/build/test-results/test/*.xml + paths: + - services/pcp-srpring-boot-admin-server/build/reports/tests/ + expire_in: 7 days + +spring-boot-admin:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .spring-boot-admin:rules:build-and-image + - .spring-boot-admin:gradle-cache + needs: + - job: spring-boot-admin:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-srpring-boot-admin-server:bootJar + artifacts: + when: always + paths: + - services/pcp-srpring-boot-admin-server/build/libs/*.jar + expire_in: 7 days + +spring-boot-admin:package: + stage: package + extends: + - .spring-boot-admin:rules:build-and-image + - .spring-boot-admin:docker-build + needs: + - job: spring-boot-admin:build + artifacts: true + script: + - 'echo "SPRING_BOOT_ADMIN_IMAGE_REPOSITORY=$SPRING_BOOT_ADMIN_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$SPRING_BOOT_ADMIN_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$SPRING_BOOT_ADMIN_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$SPRING_BOOT_ADMIN_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$SPRING_BOOT_ADMIN_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$SPRING_BOOT_ADMIN_IMAGE_REPOSITORY:$SPRING_BOOT_ADMIN_IMAGE_TAG" docker-context + - docker push "$SPRING_BOOT_ADMIN_IMAGE_REPOSITORY:$SPRING_BOOT_ADMIN_IMAGE_TAG" + - printf "SPRING_BOOT_ADMIN_IMAGE=%s\n" "$SPRING_BOOT_ADMIN_IMAGE_REPOSITORY:$SPRING_BOOT_ADMIN_IMAGE_TAG" > spring-boot-admin-image.env + artifacts: + reports: + dotenv: spring-boot-admin-image.env + expire_in: 7 days + +spring-boot-admin:deploy:dev: + stage: deploy + extends: + - .spring-boot-admin:rules:deploy-dev + - .spring-boot-admin:helm-deploy + needs: + - job: spring-boot-admin:package + artifacts: true + environment: + name: dev/pcp-spring-boot-admin-server + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${SPRING_BOOT_ADMIN_IMAGE%:*}"' + - 'export IMAGE_TAG="${SPRING_BOOT_ADMIN_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$SPRING_BOOT_ADMIN_HELM_RELEASE" + "$SPRING_BOOT_ADMIN_HELM_CHART_DIR" + --namespace "$SPRING_BOOT_ADMIN_K8S_NAMESPACE_DEV" + --create-namespace + --values "$SPRING_BOOT_ADMIN_HELM_CHART_DIR/values.yaml" + --values "$SPRING_BOOT_ADMIN_HELM_CHART_DIR/values-dev.yaml" + --set-string image.repository="$IMAGE_REPOSITORY" + --set-string image.tag="$IMAGE_TAG" + --set-string app.profile=dev + --set-string app.configLabel=dev + --atomic + --wait + --timeout 15m diff --git a/.gitlab/ci/pcp-stations-service.yml b/.gitlab/ci/pcp-stations-service.yml new file mode 100644 index 0000000..22b4590 --- /dev/null +++ b/.gitlab/ci/pcp-stations-service.yml @@ -0,0 +1,245 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + STATIONS_SERVICE_DIR: "services/pcp-stations-service" + STATIONS_HELM_CHART_DIR: "helm/pcp-stations-service" + STATIONS_HELM_RELEASE: "pcp-stations-service" + STATIONS_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-stations-service" + STATIONS_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + STATIONS_K8S_NAMESPACE_DEV: "pcp-dev" + STATIONS_K8S_NAMESPACE_MASTER: "pcp-master" + +.stations_changes: &stations_changes + - services/pcp-stations-service/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-stations-service.* + - helm/pcp-stations-service/**/* + - deploy/**/* + +.stations:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *stations_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *stations_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *stations_changes + - when: never + +.stations:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *stations_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *stations_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *stations_changes + - when: never + +.stations:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *stations_changes + when: manual + - when: never + +.stations:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *stations_changes + when: manual + - when: never + +.stations:gradle-cache: + cache: + key: "gradle-stations-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.stations:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.stations:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +stations:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .stations:rules:test + - .stations:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-stations-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-stations-service/build/test-results/test/*.xml + paths: + - services/pcp-stations-service/build/reports/tests/ + expire_in: 7 days + +stations:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .stations:rules:build-and-image + - .stations:gradle-cache + needs: + - job: stations:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-stations-service:bootJar + artifacts: + when: always + paths: + - services/pcp-stations-service/build/libs/*.jar + expire_in: 7 days + +stations:package: + stage: package + extends: + - .stations:rules:build-and-image + - .stations:docker-build + needs: + - job: stations:build + artifacts: true + script: + - 'echo "STATIONS_IMAGE_REPOSITORY=$STATIONS_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$STATIONS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$STATIONS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$STATIONS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$STATIONS_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$STATIONS_IMAGE_REPOSITORY:$STATIONS_IMAGE_TAG" docker-context + - docker push "$STATIONS_IMAGE_REPOSITORY:$STATIONS_IMAGE_TAG" + - printf "STATIONS_IMAGE=%s\n" "$STATIONS_IMAGE_REPOSITORY:$STATIONS_IMAGE_TAG" > stations-image.env + artifacts: + reports: + dotenv: stations-image.env + expire_in: 7 days + +stations:deploy:dev: + stage: deploy + extends: + - .stations:rules:deploy-dev + - .stations:helm-deploy + needs: + - job: stations:package + artifacts: true + environment: + name: dev/pcp-stations-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${STATIONS_IMAGE%:*}"' + - 'export IMAGE_TAG="${STATIONS_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$STATIONS_HELM_RELEASE" + "$STATIONS_HELM_CHART_DIR" + --namespace "$STATIONS_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$STATIONS_HELM_CHART_DIR/values.yaml" + -f "$STATIONS_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +stations:deploy:master: + stage: deploy + extends: + - .stations:rules:deploy-master + - .stations:helm-deploy + needs: + - job: stations:package + artifacts: true + environment: + name: master/pcp-stations-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${STATIONS_IMAGE%:*}"' + - 'export IMAGE_TAG="${STATIONS_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$STATIONS_HELM_RELEASE" + "$STATIONS_HELM_CHART_DIR" + --namespace "$STATIONS_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$STATIONS_HELM_CHART_DIR/values.yaml" + -f "$STATIONS_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-tgu-service.yml b/.gitlab/ci/pcp-tgu-service.yml new file mode 100644 index 0000000..539ea63 --- /dev/null +++ b/.gitlab/ci/pcp-tgu-service.yml @@ -0,0 +1,203 @@ +variables: + TGU_SERVICE_DIR: "services/pcp-tgu-service" + TGU_HELM_CHART_DIR: "helm/pcp-tgu-service" + TGU_HELM_RELEASE: "pcp-tgu-service" + TGU_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-tgu-service" + TGU_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + TGU_K8S_NAMESPACE_DEV: "pcp-dev" + TGU_K8S_NAMESPACE_MASTER: "pcp-master" + +.tgu_changes: &tgu_changes + - services/pcp-tgu-service/**/* +# - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-tgu-service.yml + - helm/pcp-tgu-service/**/* + - deploy/**/* + +.tgu:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *tgu_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *tgu_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *tgu_changes + - when: never + +.tgu:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *tgu_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *tgu_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *tgu_changes + - when: never + +.tgu:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *tgu_changes + when: manual + - when: never + +.tgu:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *tgu_changes + when: manual + - when: never + +.tgu:gradle-cache: + cache: + key: "gradle-tgu-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.tgu:docker-build: + image: docker:26 + services: + - name: docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.tgu:helm-deploy: + image: + name: alpine/helm:3.16.4 + entrypoint: [""] + before_script: + - mkdir -p ~/.kube + - helm version + +tgu:test: + stage: test + image: eclipse-temurin:21-jdk + extends: + - .tgu:rules:test + - .tgu:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-tgu-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-tgu-service/build/test-results/test/*.xml + paths: + - services/pcp-tgu-service/build/reports/tests/ + expire_in: 7 days + +tgu:build: + stage: build + image: eclipse-temurin:21-jdk + extends: + - .tgu:rules:build-and-image + - .tgu:gradle-cache + needs: + - job: tgu:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-tgu-service:bootJar + artifacts: + when: always + paths: + - services/pcp-tgu-service/build/libs/*.jar + expire_in: 7 days + +tgu:package: + stage: package + extends: + - .tgu:rules:build-and-image + - .tgu:docker-build + needs: + - job: tgu:build + artifacts: true + script: + - 'echo "TGU_IMAGE_REPOSITORY=$TGU_IMAGE_REPOSITORY"' + - mkdir -p docker-context/build/libs + - cp "$TGU_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$TGU_SERVICE_DIR"/build/libs/*.jar docker-context/build/libs/ + - docker build --file docker-context/Dockerfile --tag "$TGU_IMAGE_REPOSITORY:$TGU_IMAGE_TAG" docker-context + - docker push "$TGU_IMAGE_REPOSITORY:$TGU_IMAGE_TAG" + - printf "TGU_IMAGE=%s\n" "$TGU_IMAGE_REPOSITORY:$TGU_IMAGE_TAG" > tgu-image.env + artifacts: + reports: + dotenv: tgu-image.env + expire_in: 7 days + +tgu:deploy:dev: + stage: deploy + extends: + - .tgu:rules:deploy-dev + - .tgu:helm-deploy + needs: + - job: tgu:package + artifacts: true + environment: + name: dev/pcp-tgu-service + script: + - 'echo "$KUBE_CONFIG_DEV_B64" | base64 -d > ~/.kube/config' + - chmod 600 ~/.kube/config + - 'export IMAGE_REPOSITORY="${TGU_IMAGE%:*}"' + - 'export IMAGE_TAG="${TGU_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$TGU_HELM_RELEASE" + "$TGU_HELM_CHART_DIR" + --namespace "$TGU_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$TGU_HELM_CHART_DIR/values.yaml" + -f "$TGU_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +tgu:deploy:master: + stage: deploy + extends: + - .tgu:rules:deploy-master + - .tgu:helm-deploy + needs: + - job: tgu:package + artifacts: true + environment: + name: master/pcp-tgu-service + script: + - 'echo "$KUBE_CONFIG_MASTER_B64" | base64 -d > ~/.kube/config' + - chmod 600 ~/.kube/config + - 'export IMAGE_REPOSITORY="${TGU_IMAGE%:*}"' + - 'export IMAGE_TAG="${TGU_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$TGU_HELM_RELEASE" + "$TGU_HELM_CHART_DIR" + --namespace "$TGU_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$TGU_HELM_CHART_DIR/values.yaml" + -f "$TGU_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/pcp-ui-service.yml b/.gitlab/ci/pcp-ui-service.yml new file mode 100644 index 0000000..21111ae --- /dev/null +++ b/.gitlab/ci/pcp-ui-service.yml @@ -0,0 +1,246 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + UI_SERVICE_DIR: "services/pcp-ui-service" + UI_HELM_CHART_DIR: "helm/pcp-ui-service" + UI_HELM_RELEASE: "pcp-ui-service" + UI_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-ui-service" + UI_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + UI_K8S_NAMESPACE_DEV: "pcp-dev" + UI_K8S_NAMESPACE_MASTER: "pcp-master" + +.ui_changes: &ui_changes + - services/pcp-ui-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/pcp-ui-service.yml + - helm/pcp-ui-service/**/* + - deploy/**/* + +.ui:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *ui_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *ui_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *ui_changes + - when: never + +.ui:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *ui_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *ui_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *ui_changes + - when: never + +.ui:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *ui_changes + when: manual + - when: never + +.ui:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *ui_changes + when: manual + - when: never + +.ui:gradle-cache: + cache: + key: "gradle-ui-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.ui:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.ui:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +ui:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .ui:rules:test + - .ui:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-ui-service:test + artifacts: + when: always + reports: + junit: + - services/pcp-ui-service/build/test-results/test/*.xml + paths: + - services/pcp-ui-service/build/reports/tests/ + expire_in: 7 days + +ui:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .ui:rules:build-and-image + - .ui:gradle-cache + needs: + - job: ui:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:pcp-ui-service:bootJar + artifacts: + when: always + paths: + - services/pcp-ui-service/build/libs/*.jar + expire_in: 7 days + +ui:package: + stage: package + extends: + - .ui:rules:build-and-image + - .ui:docker-build + needs: + - job: ui:build + artifacts: true + script: + - 'echo "UI_IMAGE_REPOSITORY=$UI_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$UI_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$UI_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$UI_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$UI_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$UI_IMAGE_REPOSITORY:$UI_IMAGE_TAG" docker-context + - docker push "$UI_IMAGE_REPOSITORY:$UI_IMAGE_TAG" + - printf "UI_IMAGE=%s\n" "$UI_IMAGE_REPOSITORY:$UI_IMAGE_TAG" > ui-image.env + artifacts: + reports: + dotenv: ui-image.env + expire_in: 7 days + +ui:deploy:dev: + stage: deploy + extends: + - .ui:rules:deploy-dev + - .ui:helm-deploy + needs: + - job: ui:package + artifacts: true + environment: + name: dev/pcp-ui-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${UI_IMAGE%:*}"' + - 'export IMAGE_TAG="${UI_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$UI_HELM_RELEASE" + "$UI_HELM_CHART_DIR" + --namespace "$UI_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$UI_HELM_CHART_DIR/values.yaml" + -f "$UI_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +ui:deploy:master: + stage: deploy + extends: + - .ui:rules:deploy-master + - .ui:helm-deploy + needs: + - job: ui:package + artifacts: true + environment: + name: master/pcp-ui-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${UI_IMAGE%:*}"' + - 'export IMAGE_TAG="${UI_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$UI_HELM_RELEASE" + "$UI_HELM_CHART_DIR" + --namespace "$UI_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 10m + -f "$UI_HELM_CHART_DIR/values.yaml" + -f "$UI_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/slots-service.yml b/.gitlab/ci/slots-service.yml new file mode 100644 index 0000000..52749a2 --- /dev/null +++ b/.gitlab/ci/slots-service.yml @@ -0,0 +1,256 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + SLOTS_SERVICE_DIR: "services/slots-service" + SLOTS_HELM_CHART_DIR: "helm/slots-service" + SLOTS_HELM_RELEASE: "slots-service" + SLOTS_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/slots-service" + SLOTS_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + SLOTS_K8S_NAMESPACE_DEV: "pcp-dev" + SLOTS_K8S_NAMESPACE_MASTER: "pcp-master" + +.slots_changes: &slots_changes + - services/slots-service/**/* + - libs/ballistics-lib/**/* + - libs/pcp-types-lib/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/slots-service.yml + - helm/slots-service/**/* + - deploy/**/* + +.slots:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *slots_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *slots_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *slots_changes + - when: never + +.slots:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *slots_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *slots_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *slots_changes + - when: never + +.slots:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *slots_changes + when: manual + - when: never + +.slots:rules:deploy-master: + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *slots_changes + when: manual + - when: never + +.slots:gradle-cache: + cache: + key: "gradle-slots-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.slots:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.slots:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +slots:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + services: + - name: repo.nstart.cloud/postgis/postgis:16-3.4 + alias: postgres + variables: + POSTGRES_DB: "pcp_slots" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "password" + SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/pcp_slots" + SPRING_DATASOURCE_USERNAME: "postgres" + SPRING_DATASOURCE_PASSWORD: "password" + extends: + - .slots:rules:test + - .slots:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:slots-service:test + artifacts: + when: always + reports: + junit: + - services/slots-service/build/test-results/test/*.xml + paths: + - services/slots-service/build/reports/tests/ + expire_in: 7 days + +slots:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .slots:rules:build-and-image + - .slots:gradle-cache + needs: + - job: slots:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:slots-service:bootJar + artifacts: + when: always + paths: + - services/slots-service/build/libs/*.jar + expire_in: 7 days + +slots:package: + stage: package + extends: + - .slots:rules:build-and-image + - .slots:docker-build + needs: + - job: slots:build + artifacts: true + script: + - 'echo "SLOTS_IMAGE_REPOSITORY=$SLOTS_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$SLOTS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$SLOTS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$SLOTS_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$SLOTS_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$SLOTS_IMAGE_REPOSITORY:$SLOTS_IMAGE_TAG" docker-context + - docker push "$SLOTS_IMAGE_REPOSITORY:$SLOTS_IMAGE_TAG" + - printf "SLOTS_IMAGE=%s\n" "$SLOTS_IMAGE_REPOSITORY:$SLOTS_IMAGE_TAG" > slots-image.env + artifacts: + reports: + dotenv: slots-image.env + expire_in: 7 days + +slots:deploy:dev: + stage: deploy + extends: + - .slots:rules:deploy-dev + - .slots:helm-deploy + needs: + - job: slots:package + artifacts: true + environment: + name: dev/pcp-slots-service + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${SLOTS_IMAGE%:*}"' + - 'export IMAGE_TAG="${SLOTS_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$SLOTS_HELM_RELEASE" + "$SLOTS_HELM_CHART_DIR" + --namespace "$SLOTS_K8S_NAMESPACE_DEV" + --create-namespace + --wait + --atomic + --timeout 30m + -f "$SLOTS_HELM_CHART_DIR/values.yaml" + -f "$SLOTS_HELM_CHART_DIR/values-dev.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=dev + --set app.configLabel=dev + +slots:deploy:master: + stage: deploy + extends: + - .slots:rules:deploy-master + - .slots:helm-deploy + needs: + - job: slots:package + artifacts: true + environment: + name: master/pcp-slots-service + script: + - | + if [ -n "${KUBE_CONFIG_MASTER:-}" ]; then + printf '%s' "$KUBE_CONFIG_MASTER" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_MASTER_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_MASTER_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_MASTER_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_MASTER_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_MASTER_B64 is not valid base64. Set KUBE_CONFIG_MASTER to raw kubeconfig or KUBE_CONFIG_MASTER_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_MASTER or KUBE_CONFIG_MASTER_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - 'export IMAGE_REPOSITORY="${SLOTS_IMAGE%:*}"' + - 'export IMAGE_TAG="${SLOTS_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$SLOTS_HELM_RELEASE" + "$SLOTS_HELM_CHART_DIR" + --namespace "$SLOTS_K8S_NAMESPACE_MASTER" + --create-namespace + --wait + --atomic + --timeout 30m + -f "$SLOTS_HELM_CHART_DIR/values.yaml" + -f "$SLOTS_HELM_CHART_DIR/values-master.yaml" + --set image.repository="$IMAGE_REPOSITORY" + --set image.tag="$IMAGE_TAG" + --set app.profile=master + --set app.configLabel=master diff --git a/.gitlab/ci/spring-cloud-config-server.yml b/.gitlab/ci/spring-cloud-config-server.yml new file mode 100644 index 0000000..e1e45b8 --- /dev/null +++ b/.gitlab/ci/spring-cloud-config-server.yml @@ -0,0 +1,214 @@ +variables: + GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle" + + CONFIG_SERVER_SERVICE_DIR: "services/spring-cloud-config-server" + CONFIG_SERVER_HELM_CHART_DIR: "helm/spring-cloud-config-server" + CONFIG_SERVER_HELM_RELEASE: "spring-cloud-config-server" + CONFIG_SERVER_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-cloud-config-server" + CONFIG_SERVER_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + CONFIG_SERVER_K8S_NAMESPACE_DEV: "pcp-dev" + CONFIG_SERVER_GIT_SECRET_NAME: "spring-cloud-config-server-git" + CONFIG_GIT_URI: "https://git.nstart.cloud/emelianov.an/pcp.git" + CONFIG_GIT_USERNAME: "gitlab+deploy-token-1" + CONFIG_GIT_DEFAULT_LABEL: "master" + CONFIG_GIT_SEARCH_PATHS: "config-repo" + CONFIG_GIT_SKIP_SSL_VALIDATION: "true" + +.config_server_changes: &config_server_changes + - services/spring-cloud-config-server/**/* + - build.gradle.kts + - settings.gradle.kts + - .gitlab/ci/spring-cloud-config-server.yml + - helm/spring-cloud-config-server/**/* + - deploy/**/* + +.config-server:rules:build-and-image: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *config_server_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *config_server_changes + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *config_server_changes + - when: never + +.config-server:rules:test: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: *config_server_changes + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *config_server_changes + when: manual + - if: '$CI_COMMIT_BRANCH == "master"' + changes: *config_server_changes + - when: never + +.config-server:rules:deploy-dev: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + changes: *config_server_changes + when: manual + - when: never + +.config-server:gradle-cache: + cache: + key: "gradle-config-server-$CI_DEFAULT_BRANCH" + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +.config-server:docker-build: + image: repo.nstart.cloud/library/docker:26 + services: + - name: repo.nstart.cloud/library/docker:26-dind + alias: docker + command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + DOCKER_BUILDKIT: "1" + before_script: + - docker version + - docker info + +.config-server:helm-deploy: + image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0 + before_script: + - mkdir -p /root/.kube + - helm version + after_script: + - rm -f /root/.kube/config + +config-server:test: + stage: test + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .config-server:rules:test + - .config-server:gradle-cache + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:spring-cloud-config-server:test + artifacts: + when: always + reports: + junit: + - services/spring-cloud-config-server/build/test-results/test/*.xml + paths: + - services/spring-cloud-config-server/build/reports/tests/ + expire_in: 7 days + +config-server:build: + stage: build + image: repo.nstart.cloud/library/gradle:jdk17-ubi10 + extends: + - .config-server:rules:build-and-image + - .config-server:gradle-cache + needs: + - job: config-server:test + artifacts: true + before_script: + - chmod +x gradlew + script: + - ./gradlew --no-daemon :services:spring-cloud-config-server:bootJar + artifacts: + when: always + paths: + - services/spring-cloud-config-server/build/libs/*.jar + expire_in: 7 days + +config-server:package: + stage: package + extends: + - .config-server:rules:build-and-image + - .config-server:docker-build + needs: + - job: config-server:build + artifacts: true + script: + - 'echo "CONFIG_SERVER_IMAGE_REPOSITORY=$CONFIG_SERVER_IMAGE_REPOSITORY"' + - | + JAR_COUNT="$(find "$CONFIG_SERVER_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)" + + if [ "$JAR_COUNT" -ne 1 ]; then + echo "Expected exactly one executable jar, found: $JAR_COUNT" + find "$CONFIG_SERVER_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true + exit 1 + fi + + JAR_FILE="$(find "$CONFIG_SERVER_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)" + - rm -rf docker-context + - mkdir -p docker-context/build/libs + - cp "$CONFIG_SERVER_SERVICE_DIR/Dockerfile" docker-context/Dockerfile + - cp "$JAR_FILE" docker-context/build/libs/app.jar + - find docker-context -maxdepth 4 -type f -print -exec ls -la {} \; + - docker build --file docker-context/Dockerfile --tag "$CONFIG_SERVER_IMAGE_REPOSITORY:$CONFIG_SERVER_IMAGE_TAG" docker-context + - docker push "$CONFIG_SERVER_IMAGE_REPOSITORY:$CONFIG_SERVER_IMAGE_TAG" + - printf "CONFIG_SERVER_IMAGE=%s\n" "$CONFIG_SERVER_IMAGE_REPOSITORY:$CONFIG_SERVER_IMAGE_TAG" > config-server-image.env + artifacts: + reports: + dotenv: config-server-image.env + expire_in: 7 days + +config-server:deploy:dev: + stage: deploy + extends: + - .config-server:rules:deploy-dev + - .config-server:helm-deploy + needs: + - job: config-server:package + artifacts: true + environment: + name: dev/spring-cloud-config-server + script: + - | + if [ -n "${KUBE_CONFIG_DEV:-}" ]; then + printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config + elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then + if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then + printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config + elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then + echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig." + exit 1 + fi + else + echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set." + exit 1 + fi + - chmod 600 /root/.kube/config + - export KUBECONFIG=/root/.kube/config + - | + if [ -z "${CONFIG_GIT_TOKEN:-}" ]; then + echo "CONFIG_GIT_TOKEN must be set as a GitLab CI/CD variable." + exit 1 + fi + - kubectl create namespace "$CONFIG_SERVER_K8S_NAMESPACE_DEV" --dry-run=client -o yaml | kubectl apply -f - + - > + kubectl create secret generic "$CONFIG_SERVER_GIT_SECRET_NAME" + --namespace "$CONFIG_SERVER_K8S_NAMESPACE_DEV" + --from-literal=token="$CONFIG_GIT_TOKEN" + --dry-run=client + -o yaml + | kubectl apply -f - + - 'export IMAGE_REPOSITORY="${CONFIG_SERVER_IMAGE%:*}"' + - 'export IMAGE_TAG="${CONFIG_SERVER_IMAGE##*:}"' + - 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"' + - > + helm upgrade --install "$CONFIG_SERVER_HELM_RELEASE" + "$CONFIG_SERVER_HELM_CHART_DIR" + --namespace "$CONFIG_SERVER_K8S_NAMESPACE_DEV" + --create-namespace + --values "$CONFIG_SERVER_HELM_CHART_DIR/values.yaml" + --values "$CONFIG_SERVER_HELM_CHART_DIR/values-dev.yaml" + --set-string image.repository="$IMAGE_REPOSITORY" + --set-string image.tag="$IMAGE_TAG" + --set-string app.configGitUri="$CONFIG_GIT_URI" + --set-string app.configGitUsername="$CONFIG_GIT_USERNAME" + --set-string app.configGitDefaultLabel="$CONFIG_GIT_DEFAULT_LABEL" + --set-string app.configGitSearchPaths="$CONFIG_GIT_SEARCH_PATHS" + --set-string app.configGitSkipSslValidation="$CONFIG_GIT_SKIP_SSL_VALIDATION" + --set-string gitTokenSecret.name="$CONFIG_SERVER_GIT_SECRET_NAME" + --atomic + --wait + --timeout 15m diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b038345 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,273 @@ +# AGENTS.md + +## Назначение + +Этот документ задаёт правила работы агента и базовые инженерные правила для изменений в репозитории. + +Цель агента: +- вносить минимальные, безопасные и ревьюопригодные изменения; +- сохранять архитектурные границы; +- не ломать существующие контракты без явного запроса; +- следовать уже используемым в репозитории паттернам и стилю. + +Этот документ **не** является полным платформенным стандартом для BPM/Kafka. Такие правила вынесены в `docs/standards/PLATFORM_STANDARD.md`. + +--- + +## Дополнительные обязательные документы + +При работе в этом репозитории: + +- Для изменений, связанных с BPM / Zeebe / Camunda / Kafka / retries / DLQ / compensation / schema evolution, перед изменением кода прочитай `docs/standards/PLATFORM_STANDARD.md`. +- Перед завершением нетривиальных изменений проверь `docs/standards/CHECKLIST.md`. +- Если задача затрагивает только обычный Kotlin/Spring-код без BPM/Kafka-аспектов, достаточно следовать правилам этого `AGENTS.md`. + +--- + +## Обязательный стиль работы + +Перед внесением изменений: + +1. Найди похожую реализацию в том же модуле, сервисе или соседнем слое. +2. Следуй уже принятому стилю проекта и существующей структуре. +3. Прочитай полный execution path до редактирования кода. +4. Держи решение в правильном архитектурном слое. +5. Сделай минимальное изменение, которое полностью решает задачу. +6. Обнови или добавь тесты для изменённого поведения. + +Для нетривиальных задач: + +1. Кратко определи затронутые модули и файлы. +2. Явно зафиксируй ключевые допущения, если есть неоднозначность. +3. Предпочитай самый безопасный и наименее сложный вариант вместо редизайна. + +Если требования неоднозначны: +- не придумывай редизайн; +- сохраняй текущее поведение, если нет явного запроса на его изменение; +- явно фиксируй допущения; +- предпочитай production safety «красивым» решениям. + +--- + +## Предпочтение в дизайне решения + +По умолчанию предпочитай самое простое и понятное решение, которое корректно удовлетворяет текущим требованиям. + +Более сложная архитектура, абстракции, дополнительные слои или более общие конструкции допустимы только тогда, когда более простое решение заметно ухудшит хотя бы одно из следующего: +- корректность; +- понятность и поддерживаемость; +- границы ответственности; +- тестируемость; +- надёжность; +- интеграцию с существующей архитектурой. + +Не усложняй решение как подготовку к возможным будущим требованиям. +Если есть несколько рабочих вариантов, выбирай наименее сложный, который не ухудшает качество решения. + +--- + +## Базовые предположения по стеку + +- Язык: Kotlin +- Framework: Spring Boot +- Возможные интеграции: + - REST + - Kafka + - BPM / Zeebe / Camunda 8 + - database persistence + - external HTTP/gRPC APIs + +Следуй реальной структуре и библиотекам, уже используемым в репозитории. +Не вводи новый стиль, если в проекте уже есть устоявшийся. + +--- + +## Архитектурные границы + +### Ключевой принцип + +Сервис владеет доменным состоянием и бизнес-правилами. + +### Владение по слоям + +- `controller/api` -> только transport +- `application/service/use case` -> orchestration внутри границы сервиса +- `domain` -> бизнес-правила, инварианты, решения +- `repository/adapter/client` -> только persistence и внешняя коммуникация + +### Где нельзя размещать бизнес-логику + +Нельзя размещать доменную бизнес-логику в: +- controllers +- repository implementations +- Kafka listeners / consumers +- Zeebe workers +- external client adapters +- BPMN process definitions + +--- + +## Правила Kotlin + +- Предпочитай явный и читаемый код. +- Делай функции небольшими и отражающими намерение. +- Предпочитай `val` вместо `var`, если мутация не нужна. +- Избегай чрезмерно «умных» chain/scope-function конструкций, если они ухудшают читаемость. +- Используй понятные имена; не вводи аббревиатуры без необходимости. +- Не вводи DSL-подобный или чрезмерно функциональный стиль, если он не доминирует в проекте. +- Сохраняй текущий подход проекта к null-safety. +- Не заменяй явные ошибки на `null`-driven control flow. +- Предпочитай прямой и понятный код вместо «умных», слишком универсальных или академичных решений. + +### Правило комментариев для Kotlin/Spring кода + +Комментарии обязательны там, где они реально помогают сопровождению кода: + +- для публичных функций и публичных контрактов, если их назначение неочевидно из сигнатуры; +- для сложных бизнес-правил, инвариантов и ограничений; +- для retry / compensation / idempotency / transactional логики; +- для неочевидных решений, важных допущений и edge cases; +- для переменных и локальных вычислений только тогда, когда без комментария намерение неясно. + +Не нужно комментировать каждую переменную или каждую функцию механически. +Комментарии должны объяснять цель и reasoning, а не пересказывать синтаксис. + +Плохой комментарий: +- `// increment counter` + +Хороший комментарий: +- `// Хранит количество уже выполненных попыток доставки для этой операции, чтобы retry-логика могла определить, нужно ли повторить обработку или отправить сообщение в DLQ.` + +--- + +## Правила Spring Boot + +- Контроллеры должны оставаться тонкими. +- Контроллеры должны только: + - принимать и валидировать вход; + - маппить transport-модели; + - вызывать application/service layer; + - возвращать response. +- Бизнес-правила не должны жить в контроллерах. +- Репозитории не должны содержать бизнес-решения. +- Transaction boundaries должны находиться в service/application layer, если модуль уже не использует другой устоявшийся паттерн. +- Не выполняй долгий remote I/O внутри DB-транзакций без явной необходимости текущего дизайна. +- Переиспользуй существующий стиль Spring-конфигурации, properties, beans, mappers и utilities. + +--- + +## Правила API и error handling + +- Сохраняй публичные REST/gRPC контракты, если явно не запрошено иное. +- Переиспользуй существующий response/error format. +- Переиспользуй текущий validation style. +- Держи backward compatibility в фокусе. +- Если контракт меняется, обнови тесты и nearby examples/docs. + +- Не используй broad `try/catch`, скрывающий ошибки. +- Не проглатывай исключения. +- Не делай silent downgrade ошибок в дефолтные значения. +- Следуй существующему exception mapping pattern. +- Держи error handling явным и удобным для дебага. +- Избегай дублирующих шумных логов для одного и того же failure path. + +--- + +## Правила observability + +Для важных бизнес-флоу сохраняй или улучшай: +- structured logging; +- metrics; +- tracing/correlation; +- operation identifiers. + +Если меняется Kafka/BPM/external integration logic: +- сохрани или добавь correlation identifiers; +- сохрани или добавь `operation_id`, где это релевантно; +- логируй достаточно для дебага retries, duplicates и compensations; +- никогда не логируй секреты или sensitive payloads. + +--- + +## Правила тестирования + +Всегда обновляй тесты, если меняется поведение. + +Добавляй или обновляй unit tests, если меняются: +- business rules; +- branching logic; +- validation; +- idempotency behavior; +- retry/compensation behavior. + +Добавляй или обновляй integration tests, если меняются: +- Spring wiring; +- controller behavior; +- persistence behavior; +- Kafka behavior; +- Zeebe worker behavior; +- transaction behavior; +- external integration behavior. + +Каждое bug fix-изменение по возможности должно сопровождаться regression test. + +--- + +## Validation commands + +Сначала предпочитай самый маленький релевантный прогон, потом более широкий. + +Типичные команды: +- `./gradlew test` +- `./gradlew ktlintCheck` +- `./gradlew build` + +Если в репозитории есть module-specific команды, используй их, когда это уместно. + +Если команда не была запущена: +- точно укажи, какая именно команда не запускалась; +- объясни почему; +- опиши остаточный риск. + +--- + +## Правила контроля scope + +Держи изменения узко ограниченными. + +Не делай: +- переименование файлов / классов / пакетов без явной необходимости; +- перенос кода между модулями без сильной архитектурной причины; +- переформатирование несвязанных файлов; +- «попутную уборку» соседнего кода без необходимости для задачи; +- введение абстракций «на будущее». + +Предпочитай: +- локальные изменения; +- существующие абстракции; +- явные, простые, сопровождаемые решения. + +--- + +## Обязательный формат финального ответа + +В конце задачи всегда предоставляй: + +1. `Changed files` + - список изменённых файлов + +2. `What changed` + - краткое summary реализации + +3. `Architecture compliance` + - объясни, почему решение соблюдает layer boundaries + - если участвует BPM или messaging, кратко укажи, как соблюдены платформенные требования из `docs/standards/PLATFORM_STANDARD.md` + +4. `Validation` + - какие команды запускались + - результаты + +5. `Risks / assumptions` + - что не было полностью проверено + - compatibility considerations + - edge cases diff --git a/README.md b/README.md index b296e51..64552cf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# observatio-terrae +# pcp @@ -15,14 +15,14 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea ``` cd existing_repo -git remote add origin https://git.nstart.cloud/2-6/2-6-4/missionis/observatio-terrae.git +git remote add origin https://git.nstart.local/emelianov.an/pcp.git git branch -M master git push -uf origin master ``` ## Integrate with your tools -* [Set up project integrations](https://git.nstart.cloud/2-6/2-6-4/missionis/observatio-terrae/-/settings/integrations) +* [Set up project integrations](https://git.nstart.local/emelianov.an/pcp/-/settings/integrations) ## Collaborate with your team diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7c86792 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,63 @@ +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.jvm.toolchain.JavaLanguageVersion + +plugins { + kotlin("jvm") apply false + kotlin("plugin.spring") apply false + kotlin("plugin.jpa") apply false + kotlin("plugin.lombok") apply false + id("org.springframework.boot") apply false + id("io.spring.dependency-management") apply false + id("org.sonarqube") apply false +} + + +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://jitpack.io") } + + exclusiveContent { + forRepository { + maven("https://repo.osgeo.org/repository/release/") + } + filter { + includeGroup("org.geotools") + includeGroup("org.geotools.ogc") + includeGroup("javax.media") + includeGroup("it.geosolutions.jgridshift") + } + } + + + } +} + +subprojects { + plugins.withId("java") { + extensions.configure { + toolchain { + languageVersion = JavaLanguageVersion.of((property("versions.java") as String).toInt()) + } + } + } + + plugins.withId("org.springframework.boot") { + if ( + path != ":services:pcp-srpring-boot-admin-server" && + path != ":services:spring-cloud-config-server" + ) { + dependencies.add( + "implementation", + "de.codecentric:spring-boot-admin-starter-client:${property("versions.spring.boot.admin")}" + ) + } + } +} diff --git a/changeLogsDynamicSlots.md b/changeLogsDynamicSlots.md new file mode 100644 index 0000000..5cf17a0 --- /dev/null +++ b/changeLogsDynamicSlots.md @@ -0,0 +1,37 @@ +# Dynamic Slots Change Log + +1. pcp-ballistics-service + - Ужесточены уровни логирования для production/runtime окружения. + - Отключены подробные SQL/JDBC/Flyway/Spring Web логи, чтобы уменьшить шум в логах сервиса. + +2. pcp-dynamic-plan-service + - Добавлена история запусков расчета динамического плана с возможностью получать список запусков и отдельный запуск. + - Добавлено восстановление незавершенных расчетов после рестарта сервиса: зависшие `PENDING` и `RUNNING` запуски переводятся в `FAILED`. + - Добавлены режимы расчета `FULL` и `GREEDY`. + - Добавлен выбор направления витка для расчета: оба, восходящий или нисходящий. + - Добавлен флаг фильтрации маршрутов по уже покрытой территории. + - Добавлено сохранение построенных интервалов расчета и API для их получения. + - Расширен результат расчета метаданными: режим расчета, режим витка, флаг фильтрации, время последнего маршрута и процент покрытия территории. + - Добавлена greedy-логика выбора маршрутов с учетом ограничений КА, суточной загрузки, загрузки по виткам и конфликтов по времени. + - Добавлена диагностика причин, по которым интервалы не удалось закрыть маршрутом. + - Добавлен параллельный расчет параметров наблюдения по чанкам с настройками размера чанка и parallelism. + - Добавлены debug API для расчета параметров наблюдения, проверки greedy-математики и построения маршрутов по сохраненным параметрам наблюдения. + - Добавлена возможность сохранять debug-результаты параметров наблюдения во временную директорию. + - Generated debug-артефакты `obsParams` исключены из git. + - Добавлены миграции БД для новых параметров запуска и хранения интервалов. + - Обновлены unit/controller tests для новых сценариев расчета, истории запусков, интервалов и debug-функций. + +3. pcp-ui-service + - Добавлен просмотр истории запусков динамического плана. + - Добавлено открытие ранее выполненного запуска и просмотр его результата. + - Добавлены UI-контролы для выбора режима расчета, направления витка и фильтрации по покрытой территории. + - Интервал расчета в UI переведен на выбор дат с автоматическим построением границ дня. + - В результат расчета добавлено отображение режима, направления витка, фильтра покрытия, последнего маршрута и процента покрытия территории. + - Добавлены действия для отображения и скрытия результата расчета на карте. + - Добавлен слой карты для отображения интервалов и маршрутов dynamic plan в Cesium. + - Обновлен экран карты, чтобы он подключал и инициализировал слой dynamic plan. + - Обновлены стили страницы dynamic plan под историю запусков, новые параметры расчета и map controls. + - Обновлены тесты UI-контроллера под новые endpoints и поведение. + +4. spring-cloud-config-server + - Отключена регистрация config-server в Spring Boot Admin. diff --git a/config-repo/README.md b/config-repo/README.md new file mode 100644 index 0000000..6aec6d1 --- /dev/null +++ b/config-repo/README.md @@ -0,0 +1,25 @@ +# config-repo + +Файлы в этом подпроекте предназначены для `spring-cloud-config-server`. + +Ожидаемый сценарий: + +- этот каталог хранится в GitLab-репозитории с конфигами +- `spring-cloud-config-server` читает его через `CONFIG_GIT_URI` +- сервисы запрашивают конфиги по имени `spring.application.name` + +Для `tle-monitoring-service` используются файлы: + +- `tle-monitoring-service.yaml` + +Пример запроса к Config Server: + +`GET /tle-monitoring-service/local` + +Архитектурные договоренности: + +- `pcp-request-service` является единственным owner request/grid-домена. +- `pcp.services.request` должен использоваться как основной runtime endpoint для requests/cells/grid. +- `pcp.services.earth-grid` сохранен только как legacy alias и должен резолвиться в `pcp.services.request`. +- lifecycle booked slots должен идти Kafka-first; legacy HTTP `/api/slots/booking/process` не считается рабочим основным путем. +- `pcp.services.admin` указывает на `pcp-srpring-boot-admin-server`; сервисы PCP должны регистрироваться в нем через Spring Boot Admin client. diff --git a/config-repo/application-dev.yaml b/config-repo/application-dev.yaml new file mode 100644 index 0000000..27e3027 --- /dev/null +++ b/config-repo/application-dev.yaml @@ -0,0 +1,60 @@ +# TODO before merge request: вернуть старые адреса окружения. +# pcp.infra.postgres.host: 192.168.100.160 +pcp: + infra: + postgres: + host: 192.168.100.160 + port: 35400 + kafka: + host: 192.168.100.160 + port: 19092 + + network: + host: 192.168.100.160 + + ports: + config: 38888 + admin: 38889 + route-processing: 8080 + request: 8080 + ballistics: 8080 + complex-mission-service: 8080 + stations: 8080 + slots: 8080 + mission: 8080 + ui: 8080 + tgu: 8080 + tle-monitoring: 8080 + complex-mission-flow: 8080 + coverage-scheme: 8080 + satellite-catalog: 8080 + dynamic-plan: 8080 + + services: + config: http://${pcp.network.host}:${pcp.ports.config} + admin: http://${pcp.network.host}:${pcp.ports.admin} + route-processing: http://${pcp.network.host}:${pcp.ports.route-processing} + request: http://pcp-request-service:${pcp.ports.request} + earth-grid: ${pcp.services.request} + ballistics: http://pcp-ballistics-service:${pcp.ports.ballistics} + complex-mission-service: http://pcp-complex-mission-service:${pcp.ports.complex-mission-service} + stations: http://pcp-stations-service:${pcp.ports.stations} + slots: http://pcp-slots-service:${pcp.ports.slots} + mission: http://pcp-mission-planing-service:${pcp.ports.mission} + ui: http://${pcp.network.host}:${pcp.ports.ui} + tgu: http://${pcp.network.host}:${pcp.ports.tgu} + tle-monitoring: http://tle-monitoring-service:${pcp.ports.tle-monitoring} + coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme} + satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} + dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan} + +spring: + boot: + admin: + client: + enabled: true + url: + - ${pcp.services.admin} + auto-registration: true + auto-deregistration: true + register-once: true diff --git a/config-repo/application-docker-local.yaml b/config-repo/application-docker-local.yaml new file mode 100644 index 0000000..0ffcc24 --- /dev/null +++ b/config-repo/application-docker-local.yaml @@ -0,0 +1,75 @@ +pcp: + infra: + postgres: + host: postgres + port: 5432 + kafka: + host: kafka + port: 9092 + + network: + host: localhost + + ports: + config: 8888 + admin: 7000 + route-processing: 7004 + request: 7005 + ballistics: 7003 + complex-mission-service: 7002 + stations: 7009 + slots: 7006 + mission: 7010 + ui: 7008 + tgu: 7011 + tle-monitoring: 7001 + complex-mission-flow: 7007 + coverage-scheme: 7012 + satellite-catalog: 7013 + dynamic-plan: 7014 + + services: + config: http://spring-cloud-config-server:${pcp.ports.config} + admin: http://pcp-srpring-boot-admin-server:${pcp.ports.admin} + route-processing: http://pcp-route-processing-service:${pcp.ports.route-processing} + request: http://pcp-request-service:${pcp.ports.request} + earth-grid: ${pcp.services.request} + ballistics: http://pcp-ballistics-service:${pcp.ports.ballistics} + complex-mission-service: http://pcp-complex-mission-service:${pcp.ports.complex-mission-service} + stations: http://pcp-stations-service:${pcp.ports.stations} + slots: http://slots-service:${pcp.ports.slots} + mission: http://pcp-mission-planing-service:${pcp.ports.mission} + ui: http://pcp-ui-service:${pcp.ports.ui} + tgu: http://pcp-tgu-service:${pcp.ports.tgu} + tle-monitoring: http://tle-monitoring-service:${pcp.ports.tle-monitoring} + coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme} + satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} + dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan} + +spring: + kafka: + admin: + auto-create: true + listener: + missing-topics-fatal: false + +camunda: + client: + enabled: ${CAMUNDA_CLIENT_ENABLED:false} + grpc-address: http://localhost:26500 + auth: + method: none + prefer-rest-over-grpc: false + +planning: + external-update-enabled: false + +classifier: + platforms-url: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} + +tle: + polling: + enabled: ${TLE_POLLING_ENABLED:false} + +settings: + ic-service: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} diff --git a/config-repo/application-local.yaml b/config-repo/application-local.yaml new file mode 100644 index 0000000..ccd2cc3 --- /dev/null +++ b/config-repo/application-local.yaml @@ -0,0 +1,62 @@ +# Local profile defaults are intentionally overridable because developer machines +# and local Docker deployments use different network names. +pcp: + infra: + postgres: + host: ${PCP_POSTGRES_HOST:localhost} + port: ${PCP_POSTGRES_PORT:5432} + kafka: + host: ${PCP_KAFKA_HOST:localhost} + port: ${PCP_KAFKA_PORT:29092} + + + + network: + host: ${PCP_NETWORK_HOST:localhost} + + ports: + config: 38888 + admin: 7000 + route-processing: 7004 + request: 7005 + ballistics: 7003 + complex-mission-service: 7002 + stations: 7009 + slots: 7006 + mission: 7010 + ui: 7008 + tgu: 7011 + tle-monitoring: 7001 + complex-mission-flow: 7007 + coverage-scheme: 7012 + satellite-catalog: 7013 + dynamic-plan: 7014 + + services: + config: http://${pcp.network.host}:${pcp.ports.config} + admin: http://${pcp.network.host}:${pcp.ports.admin} + route-processing: http://${pcp.network.host}:${pcp.ports.route-processing} + request: http://${pcp.network.host}:${pcp.ports.request} + earth-grid: ${pcp.services.request} + ballistics: http://${pcp.network.host}:${pcp.ports.ballistics} + complex-mission-service: http://${pcp.network.host}:${pcp.ports.complex-mission-service} + stations: http://${pcp.network.host}:${pcp.ports.stations} + slots: http://${pcp.network.host}:${pcp.ports.slots} + mission: http://${pcp.network.host}:${pcp.ports.mission} + ui: http://${pcp.network.host}:${pcp.ports.ui} + tgu: http://${pcp.network.host}:${pcp.ports.tgu} + tle-monitoring: http://${pcp.network.host}:${pcp.ports.tle-monitoring} + coverage-scheme: http://${pcp.network.host}:${pcp.ports.coverage-scheme} + satellite-catalog: http://${pcp.network.host}:${pcp.ports.satellite-catalog} + dynamic-plan: http://${pcp.network.host}:${pcp.ports.dynamic-plan} + +spring: + boot: + admin: + client: + enabled: true + url: + - ${pcp.services.admin} + auto-registration: true + auto-deregistration: true + register-once: true diff --git a/config-repo/build.gradle.kts b/config-repo/build.gradle.kts new file mode 100644 index 0000000..4abfb98 --- /dev/null +++ b/config-repo/build.gradle.kts @@ -0,0 +1 @@ +description = "Configuration repository for Spring Cloud Config Server" diff --git a/config-repo/meteo-viewer.yml b/config-repo/meteo-viewer.yml new file mode 100644 index 0000000..2c59358 --- /dev/null +++ b/config-repo/meteo-viewer.yml @@ -0,0 +1,57 @@ +server: + port: 38080 + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + probes: + enabled: true + show-details: always + +meteo: + s3: + endpoint: http://minio:9999 + region: us-east-1 + access-key: ${METEO_S3_ACCESS_KEY} + secret-key: ${METEO_S3_SECRET_KEY} + bucket: meteo + object-prefix: gfs + path-style-access-enabled: true + +cloud: + layer: + layer-id: tcdc + title: TCDC — Total Cloud Cover + description: Отображение общей облачности из GFS, загруженной сервисом meteodownloader + source-parameter: TCDC + unit: "%" + min-value: 0.0 + max-value: 100.0 + + render: + derived-prefix: gfs-derived/tcdc + catalog-prefix: gfs-derived/tcdc-catalog + frame-file-name: frame.json + pmtiles-file-name: frame.pmtiles + pmtiles-content-type: application/vnd.pmtiles + tile-format: png + tile-content-type: image/png + tile-size: 256 + min-zoom: 0 + native-max-zoom: 6 + display-max-zoom: 12 + chunk-render-parallelism: 8 + render-chunk-tile-count: 32 + max-in-flight-render-chunks: 20 + frame-render-parallelism: 2 + render-missing-on-startup: false + startup-render-limit: 4 + catalog-cache-seconds: 30 + temp-dir: ./meteo-render-temp + render-version: v5-pmtiles-direct-leaflet + single-instance-mode: true + keep-latest-runs: 1 \ No newline at end of file diff --git a/config-repo/pcp-ballistics-service.yaml b/config-repo/pcp-ballistics-service.yaml new file mode 100644 index 0000000..79f9b8a --- /dev/null +++ b/config-repo/pcp-ballistics-service.yaml @@ -0,0 +1,129 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.ballistics} + service-path: /swagger/ui + management-base-url: ${pcp.services.ballistics} + health-url: ${pcp.services.ballistics}/actuator/health + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +--- +spring: + config: + activate: + on-profile: dev + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp-ballistics-service + auto-offset-reset: earliest + template: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +app: + kafka: + topics: + satellites: pcp.satellites + +settings: + calculation-interval: 7 + +server: + port: 8080 + +--- +spring: + config: + activate: + on-profile: local + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + admin: + auto-create: ${SPRING_KAFKA_ADMIN_AUTO_CREATE:false} + consumer: + group-id: pcp-ballistics-service + auto-offset-reset: earliest + listener: + auto-startup: ${SPRING_KAFKA_LISTENER_AUTO_STARTUP:false} + template: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +app: + kafka: + topics: + satellites: pcp.satellites + +settings: + calculation-interval: 28 + ic-service: http://${pcp.network.host}:9080 + +server: + port: 7003 diff --git a/config-repo/pcp-complex-mission-service.yaml b/config-repo/pcp-complex-mission-service.yaml new file mode 100644 index 0000000..f69530e --- /dev/null +++ b/config-repo/pcp-complex-mission-service.yaml @@ -0,0 +1,141 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.complex-mission-service} + service-path: /swagger/ui + management-base-url: ${pcp.services.complex-mission-service} + health-url: ${pcp.services.complex-mission-service}/actuator/health + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +complex-plan: + batch-chunk-size: 100 + +--- +spring: + config: + activate: + on-profile: local + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_satellites + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + consumer: + group-id: pcp-complex-mission-service + +app: + kafka: + topics: + satellites: pcp.satellites + +server: + port: ${pcp.ports.complex-mission-service:7002} + +settings: + calculation-interval: 3 + ballistics-service: ${pcp.services.ballistics} + request-service: ${pcp.services.request} + earth-grid-service: ${pcp.services.request} + complex-mission-service: ${pcp.services.complex-mission-service} + satellite-catalog-service: ${pcp.services.satellite-catalog} + stations-service: ${pcp.services.stations} + slots-service: ${pcp.services.slots} + coverage-scheme-service: ${pcp.services.coverage-scheme} + +--- +spring: + config: + activate: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + consumer: + group-id: pcp-complex-mission-service + +app: + kafka: + topics: + satellites: pcp.satellites + +server: + port: 8080 + +settings: + calculation-interval: 3 + ballistics-service: ${pcp.services.ballistics} + request-service: ${pcp.services.request} + earth-grid-service: ${pcp.services.request} + complex-mission-service: ${pcp.services.complex-mission-service} + satellite-catalog-service: ${pcp.services.satellite-catalog} + stations-service: ${pcp.services.stations} + slots-service: ${pcp.services.slots} + coverage-scheme-service: ${pcp.services.coverage-scheme} diff --git a/config-repo/pcp-coverage-scheme-service.yaml b/config-repo/pcp-coverage-scheme-service.yaml new file mode 100644 index 0000000..0de3df9 --- /dev/null +++ b/config-repo/pcp-coverage-scheme-service.yaml @@ -0,0 +1,72 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.coverage-scheme} + service-path: /swagger/ui + management-base-url: ${pcp.services.coverage-scheme} + health-url: ${pcp.services.coverage-scheme}/actuator/health + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +coverage-scheme: + ballistics-service-url: ${pcp.services.ballistics} + complex-mission-service-url: ${pcp.services.complex-mission-service} + satellite-catalog-service-url: ${pcp.services.satellite-catalog} + default-roll-step-degrees: 5.0 + default-minimum-technology-overlap: 0.05 + default-allow-partial-coverage: true + boundary-touch-buffer: 0.05 + continuity-gap-tolerance: 0.000001 + default-orientation-tolerance-degrees: 15.0 + max-roll-samples-per-window: 7 + flight-line-padding-seconds: 120 + web-client-response-timeout-seconds: 30 + web-client-in-memory-size-mb: 20 + fallback-swath-width-degrees: 1.0 + fallback-shift-per-roll-degree: 0.02 + default-minimum-time-between-modes-seconds: 0 + +--- +spring: + config: + activate: + on-profile: local + +server: + port: ${pcp.ports.coverage-scheme:7012} + +--- +spring: + config: + activate: + on-profile: dev + +server: + port: 8080 diff --git a/config-repo/pcp-dynamic-plan-service.yaml b/config-repo/pcp-dynamic-plan-service.yaml new file mode 100644 index 0000000..5270aa0 --- /dev/null +++ b/config-repo/pcp-dynamic-plan-service.yaml @@ -0,0 +1,117 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.dynamic-plan} + service-path: /swagger/ui + management-base-url: ${pcp.services.dynamic-plan} + health-url: ${pcp.services.dynamic-plan}/actuator/health + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +complex-plan: + request-service-url: ${pcp.services.request} + request-selection-path: /v1/requests + ballistics-service-url: ${pcp.services.ballistics} + satellite-catalog-service-url: ${pcp.services.satellite-catalog} + report-dir: ${java.io.tmpdir}/pcp-dynamic-plan-service/reports + interval-coverage-check-enabled: false + geo-layers: + enabled: false + output-dir: ${java.io.tmpdir}/pcp-dynamic-plan-service/geo-layers + +--- +spring: + config: + activate: + on-profile: local + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_complex_plan + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration/pcp-complex-plan + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + consumer: + group-id: pcp-dynamic-plan-service + +app: + kafka: + topics: + satellites: pcp.satellites + +server: + port: ${pcp.ports.dynamic-plan:7014} + +--- +spring: + config: + activate: + on-profile: dev + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_complex_plan + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration/pcp-complex-plan + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + consumer: + group-id: pcp-dynamic-plan-service + +app: + kafka: + topics: + satellites: pcp.satellites + +server: + port: 8080 diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml new file mode 100644 index 0000000..0bfa911 --- /dev/null +++ b/config-repo/pcp-mission-planing-service.yaml @@ -0,0 +1,157 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.mission} + service-path: /swagger/ui + management-base-url: ${pcp.services.mission} + health-url: ${pcp.services.mission}/actuator/health + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +--- +spring: + config: + activate: + on-profile: local + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + consumer: + group-id: pcp-mission-planing-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + enable-auto-commit: false + listener: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB +camunda: + client: + mode: self-managed + grpc-address: http://camunda.k8s.265.nstart.local:30901 + auth: + method: none + prefer-rest-over-grpc: false +server: + port: ${pcp.ports.mission:7010} + +settings: + ballistics-service: ${pcp.services.ballistics} + complex-mission-service: ${pcp.services.complex-mission-service} + satellite-catalog-service: ${pcp.services.satellite-catalog} + stations-service: ${pcp.services.stations} + +app: + kafka: + topics: + booked-slots: pcp.slots.status.v1 + mode-status-changed: pcp.route.in.v1 + satellites: pcp.satellites + + +--- +spring: + config: + activate: + on-profile: dev + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + consumer: + group-id: pcp-mission-planing-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + enable-auto-commit: false + listener: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB +camunda: + client: + mode: self-managed + grpc-address: http://camunda.k8s.265.nstart.local:30901 + auth: + method: none + prefer-rest-over-grpc: false +server: + port: ${pcp.ports.mission:8080} + +settings: + ballistics-service: ${pcp.services.ballistics} + complex-mission-service: ${pcp.services.complex-mission-service} + satellite-catalog-service: ${pcp.services.satellite-catalog} + stations-service: ${pcp.services.stations} + +app: + kafka: + topics: + booked-slots: pcp.slots.status.v1 + mode-status-changed: pcp.route.in.v1 + satellites: pcp.satellites diff --git a/config-repo/pcp-request-service.yaml b/config-repo/pcp-request-service.yaml new file mode 100644 index 0000000..1c30333 --- /dev/null +++ b/config-repo/pcp-request-service.yaml @@ -0,0 +1,68 @@ +server: + port: ${pcp.ports.request:7005} + +pcp: + outbox: + request-completed-topic: pcp.request.completed.v1 + publish-batch-size: 50 + publish-fixed-delay-ms: 5000 + +spring: + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.request} + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp-request-service + auto-offset-reset: latest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + +springdoc: + swagger-ui: + path: /swagger/ui + +management: + endpoints: + web: + exposure: + include: health,info,metrics + +logging: + level: + root: INFO + org.hibernate.SQL: WARN + org.springframework.kafka: INFO + +app: + kafka: + topics: + request: pcp.request.in.v1 + route: pcp.route.georeference.v1 + request: + cleanup-retention-days: 30 + cleanup-cron: "0 0 3 * * *" + grid: + enabled: true diff --git a/config-repo/pcp-route-processing-service.yaml b/config-repo/pcp-route-processing-service.yaml new file mode 100644 index 0000000..a6a4614 --- /dev/null +++ b/config-repo/pcp-route-processing-service.yaml @@ -0,0 +1,57 @@ +server: + port: ${pcp.ports.route-processing:7004} + +springdoc: + swagger-ui: + path: /swagger/ui + +spring: + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.route-processing} + management-base-url: ${pcp.services.route-processing} + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_route_processing + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp-route-processing-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + enable-auto-commit: false + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + listener: + missing-topics-fatal: false + ack-mode: manual_immediate + +app: + route-passport: + consumer: + retry: + max-attempts: 3 + backoff-ms: 1000 + max-backoff-ms: 30000 + kafka: + topics: + input: pcp.request.survey-georeference.v1 + input-dlq: pcp.request.survey-georeference-dlq.v1 + processed-route: pcp.route.in.v1 + route-georeference: pcp.route.georeference.v1 diff --git a/config-repo/pcp-satellite-catalog-service.yaml b/config-repo/pcp-satellite-catalog-service.yaml new file mode 100644 index 0000000..da9b1f7 --- /dev/null +++ b/config-repo/pcp-satellite-catalog-service.yaml @@ -0,0 +1,106 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.satellite-catalog} + management-base-url: ${pcp.services.satellite-catalog} + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +--- +spring: + config: + activate: + on-profile: local + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_satellite_catalog + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +app: + kafka: + topics: + satellites: pcp.satellites + +server: + port: ${pcp.ports.satellite-catalog:7013} + +--- +spring: + config: + activate: + on-profile: dev + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + template: + default-topic: pcp.tle + datasource: + driver-class-name: org.postgresql.Driver + url: "${SPRING_DATASOURCE_URL}" + username: "${SPRING_DATASOURCE_USERNAME}" + password: "${SPRING_DATASOURCE_PASSWORD}" + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +app: + kafka: + topics: + satellites: pcp.satellites + +server: + port: 8080 diff --git a/config-repo/pcp-srpring-boot-admin-server.yaml b/config-repo/pcp-srpring-boot-admin-server.yaml new file mode 100644 index 0000000..54ba973 --- /dev/null +++ b/config-repo/pcp-srpring-boot-admin-server.yaml @@ -0,0 +1,21 @@ +server: + port: ${pcp.ports.admin:38889} + +spring: + boot: + admin: + client: + enabled: false + +management: + endpoints: + web: + exposure: + include: health,info,env,metrics + endpoint: + health: + show-details: always + +logging: + level: + de.codecentric.boot.admin: INFO diff --git a/config-repo/pcp-stations-service.yaml b/config-repo/pcp-stations-service.yaml new file mode 100644 index 0000000..3dbc099 --- /dev/null +++ b/config-repo/pcp-stations-service.yaml @@ -0,0 +1,90 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.stations} + management-base-url: ${pcp.services.stations} + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +--- +spring: + config: + activate: + on-profile: local + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_stations + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB +server: + port: ${pcp.ports.stations:7009} + +--- +spring: + config: + activate: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB +server: + port: ${pcp.ports.stations:8080} diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml new file mode 100644 index 0000000..ea06150 --- /dev/null +++ b/config-repo/pcp-tgu-service.yaml @@ -0,0 +1,117 @@ +spring: + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.tgu} + management-base-url: ${pcp.services.tgu} + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +camunda: + client: + mode: self-managed + grpc-address: http://camunda.k8s.265.nstart.local:30901 + auth: + method: none + prefer-rest-over-grpc: false + +management: + endpoints: + web: + exposure: + include: health,info,metrics + +planning: + notification-before-start-minutes: 263 + target-plan-duration-minutes: 180 + target-plan-duration-delta-minutes: 60 + external-update-enabled: true + external-points-lookahead-days: 3 + in-memory-history-days: 14 + external-update-fixed-delay-ms: 600000 + notification-check-fixed-delay-ms: 30000 + +topics: + spacecraft-points-update: pcp.tgu.kpp-update.v1 + +classifier: + platforms-url: http://nsi-classifier-api.k8s.265.nstart.local/classifiers/platforms + +--- +spring: + config: + activate: + on-profile: local + datasource: + driver-class-name: org.postgresql.Driver + url: ${DB_URL:jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_tgu} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:password} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp-tgu-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + +external-api: + base-url: ${pcp.services.ballistics} +server: + port: ${pcp.ports.tgu:7011} + +--- +spring: + config: + activate: + on-profile: dev + datasource: + driver-class-name: org.postgresql.Driver + url: ${DB_URL:jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_tgu} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:password} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp-tgu-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + admin: + auto-create: true + +external-api: + base-url: ${pcp.services.ballistics} +server: + port: ${pcp.ports.tgu:8080} diff --git a/config-repo/pcp-ui-service.yaml b/config-repo/pcp-ui-service.yaml new file mode 100644 index 0000000..99ea857 --- /dev/null +++ b/config-repo/pcp-ui-service.yaml @@ -0,0 +1,45 @@ +server: + port: ${pcp.ports.ui:7008} + shutdown: graceful + ssl: + enabled: false + +spring: + lifecycle.timeout-per-shutdown-phase: 40s + thymeleaf.reactive.max-chunk-size: 8192 + jackson: + serialization-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.ui} + management-base-url: ${pcp.services.ui} + health-url: ${pcp.services.ui}/actuator/health + codec: + max-in-memory-size: 20MB + web: + resources: + static-locations: classpath:/static/ + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +settings: + calculation-interval: 3 + request-service: ${pcp.services.request} + earth-grid-service: ${pcp.services.request} + complex-mission-service: ${pcp.services.complex-mission-service} + dynamic-plan-service: ${pcp.services.dynamic-plan} + satellite-catalog-service: ${pcp.services.satellite-catalog} + ballistics-service: ${pcp.services.ballistics} + stations-service: ${pcp.services.stations} + slots-service: ${pcp.services.slots} + mission-service: ${pcp.services.mission} + coverage-scheme-service: ${pcp.services.coverage-scheme} diff --git a/config-repo/slots-service.yaml b/config-repo/slots-service.yaml new file mode 100644 index 0000000..cf2debc --- /dev/null +++ b/config-repo/slots-service.yaml @@ -0,0 +1,157 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.slots} + service-path: /swagger/ui + management-base-url: ${pcp.services.slots} + health-url: ${pcp.services.slots}/actuator/health + + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +--- +spring: + config: + activate: + on-profile: local + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp + template: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +settings: + request-service: ${pcp.services.request} + earth-grid-service: ${pcp.services.request} + satellite-catalog-service: ${pcp.services.satellite-catalog} + +app: + kafka: + topics: + booked-slots: pcp.slots.status.v1 + satellites: pcp.satellites + +pcp: + booked-slots: + timeout-check-interval: 1h + timeout-threshold: 1h + slot-service: + calc: + parallelism: 8 + chunk-duration-seconds: 86400 + flush-threshold: 40000 + stream-batch-size: 2000 + source-margin-seconds: 120 + +server: + port: ${pcp.ports.slots:7006} + + +--- +spring: + config: + activate: + on-profile: dev + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp + template: + 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 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +settings: + request-service: ${pcp.services.request} + earth-grid-service: ${pcp.services.request} + satellite-catalog-service: ${pcp.services.satellite-catalog} +app: + kafka: + topics: + booked-slots: pcp.slots.status.v1 + satellites: pcp.satellites + +pcp: + booked-slots: + timeout-check-interval: 1h + timeout-threshold: 1h + slot-service: + calc: + parallelism: 4 + chunk-duration-seconds: 86400 + flush-threshold: 20000 + stream-batch-size: 2000 + source-margin-seconds: 120 + +server: + port: ${pcp.ports.slots:8080} + + + + diff --git a/config-repo/spring-cloud-config-server.yaml b/config-repo/spring-cloud-config-server.yaml new file mode 100644 index 0000000..2937d19 --- /dev/null +++ b/config-repo/spring-cloud-config-server.yaml @@ -0,0 +1,60 @@ +spring: + boot: + admin: + client: + enabled: true + url: + - ${pcp.services.admin} + auto-registration: true + auto-deregistration: true + register-once: true + instance: + service-base-url: ${pcp.services.config} + management-base-url: ${pcp.services.config} + cloud: + config: + server: + git: + uri: ${CONFIG_GIT_URI:} + default-label: ${CONFIG_GIT_DEFAULT_LABEL:main} + search-paths: ${CONFIG_GIT_SEARCH_PATHS:config-repo} + clone-on-start: ${CONFIG_GIT_CLONE_ON_START:false} + force-pull: ${CONFIG_GIT_FORCE_PULL:true} + timeout: ${CONFIG_GIT_TIMEOUT:10} + username: ${CONFIG_GIT_USERNAME:oauth2} + password: ${CONFIG_GIT_TOKEN:} + skipSslValidation: ${CONFIG_GIT_SKIP_SSL_VALIDATION:false} + +server: + port: ${SERVER_PORT:8888} + +management: + endpoints: + web: + exposure: + include: health,info,env,refresh + endpoint: + health: + show-details: always + +logging: + level: + org.springframework.cloud.config.server: INFO + +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: test + cloud: + config: + server: + git: + uri: https://gitlab.example.invalid/group/config-repo.git + clone-on-start: false diff --git a/config-repo/tle-monitoring-service.yaml b/config-repo/tle-monitoring-service.yaml new file mode 100644 index 0000000..e435d06 --- /dev/null +++ b/config-repo/tle-monitoring-service.yaml @@ -0,0 +1,127 @@ +spring: + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + boot: + admin: + client: + instance: + service-base-url: ${pcp.services.tle-monitoring} + management-base-url: ${pcp.services.tle-monitoring} + codec: + max-in-memory-size: 20MB + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + root: INFO + +tle: + polling: + enabled: false + timeout-seconds: 10800 + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +--- +spring: + config: + activate: + on-profile: dev + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp + auto-offset-reset: earliest + template: + default-topic: pcp.tle + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_tle + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +app: + kafka: + topics: + satellites: pcp.satellites + +settings: + complex-mission-service: ${pcp.services.complex-mission-service} + satellite-catalog-service: ${pcp.services.satellite-catalog} + +server: + port: 8080 + +--- +spring: + config: + activate: + on-profile: local + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp + auto-offset-reset: earliest + template: + default-topic: pcp.tle + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_tle + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +app: + kafka: + topics: + satellites: pcp.satellites + +settings: + complex-mission-service: ${pcp.services.complex-mission-service} + satellite-catalog-service: ${pcp.services.satellite-catalog} + +server: + port: ${pcp.ports.tle-monitoring:7001} diff --git a/deploy/local-bundle/.env.example b/deploy/local-bundle/.env.example new file mode 100644 index 0000000..a93158d --- /dev/null +++ b/deploy/local-bundle/.env.example @@ -0,0 +1,29 @@ +COMPOSE_PROJECT_NAME=pcp-local + +PCP_IMAGE_TAG=local + +POSTGRES_IMAGE=postgis/postgis:16-3.4 +KAFKA_IMAGE=apache/kafka:3.7.2 + +POSTGRES_USER=postgres +POSTGRES_PASSWORD=password +POSTGRES_PORT=5432 + +KAFKA_PORT=29092 +CONFIG_SERVER_PORT=38888 + +PCP_ADMIN_PORT=7000 +PCP_TLE_MONITORING_PORT=7001 +PCP_COMPLEX_MISSION_PORT=7002 +PCP_BALLISTICS_PORT=7003 +PCP_REQUEST_PORT=7005 +PCP_SLOTS_PORT=7006 +PCP_UI_PORT=7008 +PCP_STATIONS_PORT=7009 +PCP_MISSION_PORT=7010 +PCP_TGU_PORT=7011 +PCP_COVERAGE_SCHEME_PORT=7012 +PCP_SATELLITE_CATALOG_PORT=7013 +PCP_DYNAMIC_PLAN_PORT=7014 + +CONFIG_REPO_DIR=./config-repo diff --git a/deploy/local-bundle/README-local.md b/deploy/local-bundle/README-local.md new file mode 100644 index 0000000..fc8b1ae --- /dev/null +++ b/deploy/local-bundle/README-local.md @@ -0,0 +1,192 @@ +# PCP local Docker bundle + +Локальный bundle предназначен для запуска PCP на машине с Docker без GitLab Config Repo, Kubernetes и Helm. + +## Состав + +- `docker-compose.yml` - локальная схема запуска. +- `.env.example` - пример параметров запуска. +- `.env` - создаётся сборочным скриптом в итоговом архиве. +- `config-repo/` - локальный Spring Cloud Config repository. +- `images/` - Docker images, сохранённые через `docker save`. +- `scripts/import-images.sh` - импорт Docker images и запуск базовых сервисов. +- `scripts/init-db.sh` - создание баз данных в одном Postgres контейнере. +- `scripts/start.sh` - запуск инфраструктуры, config-server и приложений. +- `scripts/stop.sh` - остановка compose stack. +- `scripts/status.sh` - статус контейнеров. + +## Сборка архива + +Из корня репозитория: + +```bash +./deploy/local-bundle/build-local-bundle.sh +``` + +Можно задать версию: + +```bash +LOCAL_BUNDLE_VERSION=1.0.0-local ./deploy/local-bundle/build-local-bundle.sh +``` + +Результат будет создан в: + +```bash +deploy/local-bundle/build/pcp-local-bundle-.tar.gz +``` + +Скрипт сборки: + +- собирает `bootJar` для разворачиваемых сервисов; +- собирает Docker images сервисов; +- сохраняет PCP service images в один `images/pcp-services.tar`; +- по умолчанию не сохраняет публичные infra images, чтобы не раздувать архив; +- копирует `docker-compose.yml`, `config-repo`, scripts и документацию; +- формирует итоговый `tar.gz`. + +Публичные images `postgis/postgis` и `apache/kafka` будут скачаны на целевой машине командой `docker compose up`, если их нет локально. + +Если нужен полностью offline bundle, включите сохранение infra images: + +```bash +INCLUDE_INFRA_IMAGES=true ./deploy/local-bundle/build-local-bundle.sh +``` + +По умолчанию сборка `bootJar` выполняется последовательно, чтобы не упираться в heap Kotlin compiler daemon на локальной машине. Параметры можно переопределить: + +```bash +GRADLE_MAX_WORKERS=1 \ +GRADLE_JVMARGS="-Xmx2g -Dfile.encoding=UTF-8" \ +KOTLIN_DAEMON_JVMARGS="-Xmx3g -XX:MaxMetaspaceSize=768m" \ +./deploy/local-bundle/build-local-bundle.sh +``` + +## Запуск на локальной машине + +Распаковать архив: + +```bash +tar -xzf pcp-local-bundle-.tar.gz +cd pcp-local-bundle- +``` + +Проверить `.env` и при необходимости изменить порты. + +Импортировать images: + +```bash +./scripts/import-images.sh +``` + +Создать базы данных: + +```bash +./scripts/init-db.sh +``` + +Запустить приложение: + +```bash +./scripts/start.sh +``` + +`start.sh` также запускает `init-db.sh`, если не задано `SKIP_DB_INIT=true`. + +## Остановка и статус + +```bash +./scripts/status.sh +./scripts/stop.sh +``` + +Чтобы удалить volumes с данными Postgres/Kafka: + +```bash +./scripts/stop.sh -v +``` + +## Открытые порты + +- Config Server: `http://localhost:38888` +- Spring Boot Admin: `http://localhost:7000` +- TLE Monitoring: `http://localhost:7001` +- Complex Mission: `http://localhost:7002` +- Ballistics: `http://localhost:7003` +- Request Service: `http://localhost:7005` +- Slots Service: `http://localhost:7006` +- UI: `http://localhost:7008` +- Stations: `http://localhost:7009` +- Mission Planning: `http://localhost:7010` +- TGU: `http://localhost:7011` +- Coverage Scheme: `http://localhost:7012` +- Satellite Catalog: `http://localhost:7013` +- Dynamic Plan: `http://localhost:7014` +- Postgres: `localhost:5432` +- Kafka external listener: `localhost:29092` + +## Проверка готовности + +Config Server: + +```bash +curl http://localhost:38888/actuator/health +curl http://localhost:38888/pcp-ui-service/local,docker-local +``` + +UI: + +```bash +curl http://localhost:7008/actuator/health +``` + +Статус контейнеров: + +```bash +./scripts/status.sh +``` + +## Config Server + +В локальном сценарии `spring-cloud-config-server` запускается с: + +```text +SPRING_PROFILES_ACTIVE=local +CONFIG_NATIVE_SEARCH_LOCATIONS=file:/config-repo +``` + +`CONFIG_GIT_URI`, GitLab token и `git clone` не используются. + +Сервисы запускаются с профилями: + +```text +SPRING_PROFILES_ACTIVE=local,docker-local +SPRING_CLOUD_CONFIG_PROFILE=local,docker-local +SPRING_CLOUD_CONFIG_LABEL= +``` + +Профиль `local` сохраняет существующие service-specific настройки, а `docker-local` переопределяет IP-адреса на Docker Compose DNS names: + +- `postgres` +- `kafka` +- `pcp-ballistics-service` +- `slots-service` +- остальные service names из `docker-compose.yml` + +## Базы данных + +`scripts/init-db.sh` создаёт только пустые базы данных: + +- `pcp_ballistics` +- `pcp_requests` +- `pcp_missions` +- `pcp_slots` +- `pcp_complex_plan` +- `pcp_satellite_catalog` +- `pcp_stations` +- `pcp_tgu` +- `pcp_tle` +- `pcp_satellites` + +Таблицы, схемы и seed data вручную не создаются. Схемы создаются существующими Flyway migrations внутри сервисов при старте приложений. + +`pcp_satellites` добавлена дополнительно, потому что текущий `config-repo/pcp-complex-mission-service.yaml` использует эту БД для `pcp-complex-mission-service`. diff --git a/deploy/local-bundle/build-local-bundle.sh b/deploy/local-bundle/build-local-bundle.sh new file mode 100755 index 0000000..974578c --- /dev/null +++ b/deploy/local-bundle/build-local-bundle.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +version="${LOCAL_BUNDLE_VERSION:-${1:-}}" +if [[ -z "$version" ]]; then + if git -C "$repo_root" rev-parse --short HEAD >/dev/null 2>&1; then + version="$(git -C "$repo_root" rev-parse --short HEAD)" + else + version="$(date +%Y%m%d%H%M%S)" + fi +fi + +bundle_name="pcp-local-bundle-$version" +build_dir="$script_dir/build" +dist_dir="$build_dir/$bundle_name" +docker_contexts_dir="$build_dir/docker-contexts" +archive_path="$build_dir/$bundle_name.tar.gz" + +service_names=( + spring-cloud-config-server + pcp-srpring-boot-admin-server + pcp-satellite-catalog-service + pcp-ballistics-service + pcp-request-service + slots-service + pcp-complex-mission-service + pcp-mission-planing-service + pcp-coverage-scheme-service + pcp-dynamic-plan-service + pcp-stations-service + pcp-ui-service + tle-monitoring-service + pcp-tgu-service +) + +gradle_max_workers="${GRADLE_MAX_WORKERS:-1}" +gradle_jvmargs="${GRADLE_JVMARGS:--Xmx2g -Dfile.encoding=UTF-8}" +kotlin_daemon_jvmargs="${KOTLIN_DAEMON_JVMARGS:--Xmx3g -XX:MaxMetaspaceSize=768m}" +include_infra_images="${INCLUDE_INFRA_IMAGES:-false}" + +gradle_common_args=( + "--no-parallel" + "--max-workers=$gradle_max_workers" + "-Dorg.gradle.jvmargs=$gradle_jvmargs" + "-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs" +) + +infra_images=( + "${POSTGRES_IMAGE:-postgis/postgis:16-3.4}" + "${KAFKA_IMAGE:-apache/kafka:3.7.2}" +) + +sanitize_image_name() { + printf '%s' "$1" | sed 's#[/:@]#-#g' +} + +copy_single_boot_jar() { + local service_name="$1" + local context_dir="$2" + local jar_count + local jar_file + + jar_count="$( + find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l + )" + + if [[ "$jar_count" != "1" ]]; then + echo "Expected exactly one bootJar for $service_name, found $jar_count:" >&2 + find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' -print >&2 || true + exit 1 + fi + + jar_file="$( + find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1 + )" + + mkdir -p "$context_dir/build/libs" + cp "$repo_root/services/$service_name/Dockerfile" "$context_dir/Dockerfile" + cp "$jar_file" "$context_dir/build/libs/app.jar" +} + +copy_config_repo() { + local target_dir="$1" + local source_file + + mkdir -p "$target_dir" + ( + cd "$repo_root/config-repo" + find . -type f \ + ! -path './build/*' \ + ! -path './.gradle/*' \ + ! -name '*.log' \ + ! -name '*.tmp' \ + -print0 + ) | while IFS= read -r -d '' source_file; do + mkdir -p "$target_dir/$(dirname "$source_file")" + cp "$repo_root/config-repo/$source_file" "$target_dir/$source_file" + done +} + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "$command_name is required." >&2 + exit 1 + fi +} + +require_command docker +require_command tar + +echo "Building bootJar artifacts..." +echo "Gradle max workers: $gradle_max_workers" +echo "Gradle JVM args: $gradle_jvmargs" +echo "Kotlin daemon JVM args: $kotlin_daemon_jvmargs" +for service_name in "${service_names[@]}"; do + echo "Building bootJar for $service_name..." + "$repo_root/gradlew" "${gradle_common_args[@]}" ":services:$service_name:bootJar" +done + +rm -rf "$dist_dir" "$docker_contexts_dir" "$archive_path" +mkdir -p "$dist_dir/images" "$docker_contexts_dir" + +echo "Building service Docker images..." +service_images=() +for service_name in "${service_names[@]}"; do + image_name="pcp/$service_name:$version" + context_dir="$docker_contexts_dir/$service_name" + + copy_single_boot_jar "$service_name" "$context_dir" + docker build --file "$context_dir/Dockerfile" --tag "$image_name" "$context_dir" + service_images+=("$image_name") +done + +echo "Saving service Docker images into one archive..." +docker save --output "$dist_dir/images/pcp-services.tar" "${service_images[@]}" + +if [[ "$include_infra_images" == "true" ]]; then + echo "Pulling and saving infrastructure images..." + for image_name in "${infra_images[@]}"; do + image_file="$(sanitize_image_name "$image_name").tar" + docker pull "$image_name" + docker save --output "$dist_dir/images/$image_file" "$image_name" + done +else + echo "Skipping public infrastructure images. They will be pulled by docker compose on the target machine." + printf '%s\n' "${infra_images[@]}" > "$dist_dir/images/public-images.txt" +fi + +echo "Preparing bundle files..." +cp "$script_dir/docker-compose.yml" "$dist_dir/docker-compose.yml" +cp "$script_dir/.env.example" "$dist_dir/.env.example" +cp "$script_dir/.env.example" "$dist_dir/.env" +sed -i "s/^PCP_IMAGE_TAG=.*/PCP_IMAGE_TAG=$version/" "$dist_dir/.env" +cp "$script_dir/README-local.md" "$dist_dir/README-local.md" +cp -R "$script_dir/scripts" "$dist_dir/scripts" +copy_config_repo "$dist_dir/config-repo" + +find "$dist_dir/scripts" -type f -name '*.sh' -exec chmod +x {} + + +echo "Creating archive..." +tar -C "$build_dir" -czf "$archive_path" "$bundle_name" + +echo "Local bundle created: $archive_path" +echo "Bundle version: $version" +echo "To use: tar -xzf $archive_path && cd $bundle_name" diff --git a/deploy/local-bundle/docker-compose.yml b/deploy/local-bundle/docker-compose.yml new file mode 100644 index 0000000..a43c1d6 --- /dev/null +++ b/deploy/local-bundle/docker-compose.yml @@ -0,0 +1,187 @@ +name: ${COMPOSE_PROJECT_NAME:-pcp-local} + +x-pcp-service-env: &pcp-service-env + CONFIG_SERVER_URI: http://spring-cloud-config-server:8888 + CONFIG_SERVER_FAIL_FAST: "true" + SPRING_PROFILES_ACTIVE: local,docker-local + SPRING_CLOUD_CONFIG_PROFILE: local,docker-local + SPRING_CLOUD_CONFIG_LABEL: "" + SPRING_KAFKA_ADMIN_AUTO_CREATE: "true" + SPRING_KAFKA_LISTENER_AUTO_STARTUP: "true" + +x-pcp-service-common: &pcp-service-common + restart: unless-stopped + depends_on: + spring-cloud-config-server: + condition: service_started + +services: + postgres: + image: ${POSTGRES_IMAGE:-postgis/postgis:16-3.4} + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: postgres + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d postgres"] + interval: 5s + timeout: 5s + retries: 30 + + kafka: + image: ${KAFKA_IMAGE:-apache/kafka:3.7.2} + restart: unless-stopped + environment: + KAFKA_NODE_ID: "1" + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:29092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:${KAFKA_PORT:-29092} + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1" + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0" + KAFKA_LOG_DIRS: /var/lib/kafka/data + ports: + - "${KAFKA_PORT:-29092}:29092" + volumes: + - kafka-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1"] + interval: 10s + timeout: 10s + retries: 30 + + spring-cloud-config-server: + image: pcp/spring-cloud-config-server:${PCP_IMAGE_TAG:-local} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_started + environment: + SPRING_PROFILES_ACTIVE: local + CONFIG_NATIVE_SEARCH_LOCATIONS: file:/config-repo + SERVER_PORT: 8888 + ports: + - "${CONFIG_SERVER_PORT:-38888}:8888" + volumes: + - ${CONFIG_REPO_DIR:-./config-repo}:/config-repo:ro + + pcp-srpring-boot-admin-server: + <<: *pcp-service-common + image: pcp/pcp-srpring-boot-admin-server:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_ADMIN_PORT:-7000}:7000" + + tle-monitoring-service: + <<: *pcp-service-common + image: pcp/tle-monitoring-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_TLE_MONITORING_PORT:-7001}:7001" + + pcp-complex-mission-service: + <<: *pcp-service-common + image: pcp/pcp-complex-mission-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_COMPLEX_MISSION_PORT:-7002}:7002" + + pcp-ballistics-service: + <<: *pcp-service-common + image: pcp/pcp-ballistics-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_BALLISTICS_PORT:-7003}:7003" + + pcp-request-service: + <<: *pcp-service-common + image: pcp/pcp-request-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_REQUEST_PORT:-7005}:7005" + + slots-service: + <<: *pcp-service-common + image: pcp/slots-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_SLOTS_PORT:-7006}:7006" + + pcp-ui-service: + <<: *pcp-service-common + image: pcp/pcp-ui-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_UI_PORT:-7008}:7008" + + pcp-stations-service: + <<: *pcp-service-common + image: pcp/pcp-stations-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_STATIONS_PORT:-7009}:7009" + + pcp-mission-planing-service: + <<: *pcp-service-common + image: pcp/pcp-mission-planing-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_MISSION_PORT:-7010}:7010" + + pcp-tgu-service: + <<: *pcp-service-common + image: pcp/pcp-tgu-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_TGU_PORT:-7011}:7011" + + pcp-coverage-scheme-service: + <<: *pcp-service-common + image: pcp/pcp-coverage-scheme-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_COVERAGE_SCHEME_PORT:-7012}:7012" + + pcp-satellite-catalog-service: + <<: *pcp-service-common + image: pcp/pcp-satellite-catalog-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_SATELLITE_CATALOG_PORT:-7013}:7013" + + pcp-dynamic-plan-service: + <<: *pcp-service-common + image: pcp/pcp-dynamic-plan-service:${PCP_IMAGE_TAG:-local} + environment: + <<: *pcp-service-env + ports: + - "${PCP_DYNAMIC_PLAN_PORT:-7014}:7014" + +volumes: + postgres-data: + kafka-data: diff --git a/deploy/local-bundle/scripts/import-images.sh b/deploy/local-bundle/scripts/import-images.sh new file mode 100755 index 0000000..f48ad24 --- /dev/null +++ b/deploy/local-bundle/scripts/import-images.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bundle_dir="$(cd "$script_dir/.." && pwd)" +images_dir="$bundle_dir/images" + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "docker compose or docker-compose is required." >&2 + exit 1 + fi +} + +ensure_env_file() { + if [[ ! -f "$bundle_dir/.env" && -f "$bundle_dir/.env.example" ]]; then + cp "$bundle_dir/.env.example" "$bundle_dir/.env" + echo "Created .env from .env.example" + fi +} + +ensure_env_file + +if [[ ! -d "$images_dir" ]]; then + echo "Images directory not found: $images_dir" >&2 + exit 1 +fi + +shopt -s nullglob +image_archives=("$images_dir"/*.tar) +if (( ${#image_archives[@]} == 0 )); then + echo "No Docker image archives found in $images_dir" >&2 + exit 1 +fi + +for image_archive in "${image_archives[@]}"; do + echo "Loading image: $(basename "$image_archive")" + docker load -i "$image_archive" +done + +if [[ -f "$images_dir/public-images.txt" ]]; then + echo "Public images are not bundled and will be pulled by docker compose if missing:" + sed 's/^/ - /' "$images_dir/public-images.txt" +fi + +cd "$bundle_dir" +echo "Starting postgres, kafka and spring-cloud-config-server..." +compose up -d postgres kafka spring-cloud-config-server + +echo "Images imported and base services requested." diff --git a/deploy/local-bundle/scripts/init-db.sh b/deploy/local-bundle/scripts/init-db.sh new file mode 100755 index 0000000..1fa3d8f --- /dev/null +++ b/deploy/local-bundle/scripts/init-db.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bundle_dir="$(cd "$script_dir/.." && pwd)" + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "docker compose or docker-compose is required." >&2 + exit 1 + fi +} + +ensure_env_file() { + if [[ ! -f "$bundle_dir/.env" && -f "$bundle_dir/.env.example" ]]; then + cp "$bundle_dir/.env.example" "$bundle_dir/.env" + echo "Created .env from .env.example" + fi +} + +load_env() { + set -a + # shellcheck disable=SC1091 + source "$bundle_dir/.env" + set +a +} + +wait_for_postgres() { + local attempt + for attempt in {1..60}; do + if compose exec -T postgres pg_isready -U "${POSTGRES_USER:-postgres}" -d postgres >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "Postgres is not ready after 120 seconds." >&2 + exit 1 +} + +create_database_if_missing() { + local db_name="$1" + local exists + + exists="$( + compose exec -T postgres psql -U "${POSTGRES_USER:-postgres}" -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname = '$db_name'" | tr -d '[:space:]' + )" + + if [[ "$exists" == "1" ]]; then + echo "Database already exists: $db_name" + return 0 + fi + + echo "Creating database: $db_name" + compose exec -T postgres createdb -U "${POSTGRES_USER:-postgres}" "$db_name" +} + +databases=( + pcp_ballistics + pcp_requests + pcp_missions + pcp_slots + pcp_complex_plan + pcp_satellite_catalog + pcp_stations + pcp_tgu + pcp_tle + pcp_satellites +) + +ensure_env_file +load_env + +cd "$bundle_dir" +compose up -d postgres +wait_for_postgres + +for db_name in "${databases[@]}"; do + create_database_if_missing "$db_name" +done + +echo "Database initialization completed. Tables are left to service Flyway migrations." +echo "No schema objects or seed data were created by this script." diff --git a/deploy/local-bundle/scripts/start.sh b/deploy/local-bundle/scripts/start.sh new file mode 100755 index 0000000..32fe041 --- /dev/null +++ b/deploy/local-bundle/scripts/start.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bundle_dir="$(cd "$script_dir/.." && pwd)" + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "docker compose or docker-compose is required." >&2 + exit 1 + fi +} + +ensure_env_file() { + if [[ ! -f "$bundle_dir/.env" && -f "$bundle_dir/.env.example" ]]; then + cp "$bundle_dir/.env.example" "$bundle_dir/.env" + echo "Created .env from .env.example" + fi +} + +load_env() { + set -a + # shellcheck disable=SC1091 + source "$bundle_dir/.env" + set +a +} + +http_get() { + local url="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsS "$url" >/dev/null + elif command -v wget >/dev/null 2>&1; then + wget -q -O /dev/null "$url" + else + return 2 + fi +} + +wait_for_http() { + local name="$1" + local url="$2" + local attempt + + for attempt in {1..90}; do + if http_get "$url"; then + echo "$name is ready: $url" + return 0 + fi + sleep 2 + done + + echo "$name is not ready after 180 seconds: $url" >&2 + exit 1 +} + +wait_for_kafka() { + local attempt + for attempt in {1..60}; do + if compose exec -T kafka kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1; then + echo "Kafka is ready." + return 0 + fi + sleep 2 + done + + echo "Kafka is not ready after 120 seconds." >&2 + exit 1 +} + +app_services=( + pcp-srpring-boot-admin-server + pcp-satellite-catalog-service + pcp-ballistics-service + pcp-request-service + slots-service + pcp-complex-mission-service + pcp-mission-planing-service + pcp-coverage-scheme-service + pcp-dynamic-plan-service + pcp-stations-service + pcp-ui-service + tle-monitoring-service + pcp-tgu-service +) + +ensure_env_file +load_env + +cd "$bundle_dir" + +echo "Starting infrastructure..." +compose up -d postgres kafka +wait_for_kafka + +if [[ "${SKIP_DB_INIT:-false}" != "true" ]]; then + "$script_dir/init-db.sh" +fi + +echo "Starting spring-cloud-config-server..." +compose up -d spring-cloud-config-server +wait_for_http "spring-cloud-config-server" "http://localhost:${CONFIG_SERVER_PORT:-38888}/actuator/health" + +echo "Starting application services..." +compose up -d "${app_services[@]}" + +echo "Application startup requested." +compose ps diff --git a/deploy/local-bundle/scripts/status.sh b/deploy/local-bundle/scripts/status.sh new file mode 100755 index 0000000..45f12b0 --- /dev/null +++ b/deploy/local-bundle/scripts/status.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bundle_dir="$(cd "$script_dir/.." && pwd)" + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "docker compose or docker-compose is required." >&2 + exit 1 + fi +} + +cd "$bundle_dir" +compose ps diff --git a/deploy/local-bundle/scripts/stop.sh b/deploy/local-bundle/scripts/stop.sh new file mode 100755 index 0000000..f34ab28 --- /dev/null +++ b/deploy/local-bundle/scripts/stop.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bundle_dir="$(cd "$script_dir/.." && pwd)" + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "docker compose or docker-compose is required." >&2 + exit 1 + fi +} + +cd "$bundle_dir" +compose down "$@" diff --git a/docs/ci-cd-ballistics.md b/docs/ci-cd-ballistics.md new file mode 100644 index 0000000..bfaf606 --- /dev/null +++ b/docs/ci-cd-ballistics.md @@ -0,0 +1,136 @@ +# GitLab CI/CD for `pcp-ballistics-service` + +This document describes the pilot GitLab CI/CD setup for `pcp-ballistics-service`. +It is intentionally scoped to one service so the same structure can be reused later for other services in this monorepo. + +## Pipeline behavior + +The root pipeline is defined in [.gitlab-ci.yml](/home/emelianovan/WORK/Kotlin/pcp/.gitlab-ci.yml) and includes the service-specific jobs from [.gitlab/ci/pcp-ballistics-service.yml](/home/emelianovan/WORK/Kotlin/pcp/.gitlab/ci/pcp-ballistics-service.yml). + +Pipeline creation is limited by `workflow: rules`: + +- merge request: create a pipeline only when `pcp-ballistics-service` or its dependencies changed +- push to `dev`: create a pipeline only when `pcp-ballistics-service` or its dependencies changed +- push to `master`: create a pipeline only when `pcp-ballistics-service` or its dependencies changed + +No deploy jobs are created for merge requests. + +## Jobs + +The pilot flow has four stages: + +1. `ballistics:test` + Runs `:services:pcp-ballistics-service:test` outside Docker. + Publishes JUnit and HTML/JaCoCo reports. + +2. `ballistics:build` + Runs `:services:pcp-ballistics-service:bootJar` outside Docker. + Publishes `build/libs/*.jar` as an artifact. + +3. `ballistics:image` + Builds and pushes the Docker image with Kaniko using the jar artifact from the previous stage. + Tags: + - `${CI_COMMIT_SHORT_SHA}` + - `${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}` + +4. deploy jobs + - `ballistics:deploy:dev` + - `ballistics:deploy:master` + + Both are manual and use Helm: + `helm upgrade --install --wait --atomic --timeout`. + +## Branch behavior + +### `dev` + +Push to `dev`: + +- runs test, build and image jobs +- exposes a manual deploy button to the `dev` environment + +### `master` + +Push to `master`: + +- runs test, build and image jobs +- exposes a manual deploy button to the `master` environment + +### merge request + +Merge request pipeline: + +- runs test and build only +- does not create deploy jobs + +## `rules:changes` scope + +The pilot dependency map for `pcp-ballistics-service` includes: + +- `services/pcp-ballistics-service/**/*` +- `libs/pcp-types-lib/**/*` +- `libs/ballistics-lib/**/*` +- `config-repo/pcp-ballistics-service.yaml` +- `config-repo/application*.yaml` +- Gradle root files and wrapper +- CI files +- `charts/pcp-ballistics-service/**/*` + +This is the pattern to reuse later for other services: service directory + direct shared libraries + service config + CI/Helm files. + +## Helm deploy model + +The chart lives in [charts/pcp-ballistics-service](/home/emelianovan/WORK/Kotlin/pcp/charts/pcp-ballistics-service). + +The deployment model is kubeconfig-based, not GitLab Agent-based. +This keeps the pilot setup simple and practical. A later migration to GitLab Agent is still possible because deploy logic is already isolated in dedicated jobs. + +## Required GitLab CI/CD variables + +Registry auth uses standard GitLab registry variables: + +- `CI_REGISTRY` +- `CI_REGISTRY_USER` +- `CI_REGISTRY_PASSWORD` + +Deploy jobs require: + +- `BALLISTICS_KUBE_CONFIG_DEV` +- `BALLISTICS_KUBE_NAMESPACE_DEV` +- `BALLISTICS_CONFIG_SERVER_URI_DEV` +- `BALLISTICS_KUBE_CONTEXT_DEV` optional +- `BALLISTICS_CONFIG_LABEL_DEV` optional, defaults to `master` +- `BALLISTICS_SPRING_PROFILE_DEV` optional, defaults to `dev` + +- `BALLISTICS_KUBE_CONFIG_MASTER` +- `BALLISTICS_KUBE_NAMESPACE_MASTER` +- `BALLISTICS_CONFIG_SERVER_URI_MASTER` +- `BALLISTICS_KUBE_CONTEXT_MASTER` optional +- `BALLISTICS_CONFIG_LABEL_MASTER` optional, defaults to `master` +- `BALLISTICS_SPRING_PROFILE_MASTER` optional, defaults to `dev` + +`BALLISTICS_KUBE_CONFIG_*` must contain base64-encoded kubeconfig content. + +## Config server and Vault + +Runtime secrets are not duplicated in GitLab CI. + +The Helm chart only passes bootstrap parameters needed by Spring Cloud Config: + +- `CONFIG_SERVER_URI` +- `SPRING_PROFILES_ACTIVE` +- `SPRING_CLOUD_CONFIG_LABEL` +- `CONFIG_SERVER_FAIL_FAST` + +`pcp-ballistics-service` continues to obtain runtime configuration and secrets through `spring-cloud-config-server` and Vault at runtime. + +## How to scale this to other services + +To onboard another service later: + +1. create a dedicated `.gitlab/ci/.yml` +2. define that service's `rules:changes` +3. add a dedicated Helm chart +4. keep the same stage structure: + `test -> build -> image -> manual deploy` +5. keep config-service/Vault integration at runtime, without copying secrets into GitLab diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md new file mode 100644 index 0000000..27445e9 --- /dev/null +++ b/docs/standards/CHECKLIST.md @@ -0,0 +1,103 @@ +# CHECKLIST.md + +## Назначение + +Этот документ содержит краткие checklists для review/merge, Definition of Done, обязательных тестов и validation. + +Пункты ниже применяются по принципу **where relevant**: +- для обычных Kotlin/Spring-изменений достаточно общего checklist; +- для изменений в BPM / Zeebe / Camunda / Kafka / retries / DLQ / compensation / schema evolution дополнительно применяются профильные пункты; +- для критичных процессов глубина проверок и тестов должна быть пропорциональна риску изменения. + +--- + +## Review checklist + +### Для всех нетривиальных изменений + +Перед merge необходимо подтвердить: +- запрошенное поведение реализовано; +- решение осталось в корректной архитектурной границе; +- существующие публичные контракты не сломаны без явного запроса; +- добавлены или обновлены релевантные тесты; +- не внесено несвязанных правок; +- validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. + +### Дополнительно для BPM / Kafka / Zeebe / external integration изменений + +Перед merge необходимо подтвердить, где это релевантно: +- business key определён и используется консистентно; +- operation id принимается от вызывающей стороны и используется для dedup; +- ошибки классифицированы семантически; +- side effects идемпотентны или явно компенсируются; +- доменные события публикуются через Outbox; +- logs / metrics / traces содержат обязательные поля корреляции; +- тесты покрывают duplicate delivery и retry behavior; +- изменения деплоя и конфигурации трассируемы в VCS. + +--- + +## Definition of Done + +Задача считается завершённой только если одновременно выполнено всё ниже, где это релевантно: + +### Общее +1. Запрошенное поведение реализовано. +2. Решение осталось в корректной архитектурной границе. +3. Нужные тесты добавлены или обновлены. +4. Нужный validation выполнен, либо невозможность запуска объяснена. +5. Не внесено несвязанных правок. + +### Для BPM / Kafka / Zeebe / external integration +6. BPM остаётся только оркестратором, если BPM участвует. +7. Доменные правила остаются внутри сервисной границы. +8. Flows остаются безопасными при at-least-once delivery. +9. Идемпотентность обеспечена. +10. `operation_id` сохранён или добавлен, где это требуется контрактом. +11. Outbox сохранён или использован, где операция меняет состояние и публикует события. +12. Retries ограничены и управляемы. +13. DLQ handling сохранён для критичных consumers. +14. Versioning / backward compatibility сохранены. +15. Observability сохранена или улучшена. + +--- + +## Обязательные тесты для значимых изменений + +### Базовый минимум +Как минимум покрывать, где применимо: +- happy path; +- изменённые ветки branching / validation logic; +- regression scenario для bug fix. + +### Дополнительно для BPM / Kafka / Zeebe / external integration +Покрывать, где применимо: +- retryable failure с последующим успехом; +- исчерпание retries; +- ветку business error; +- обработку timeout; +- сценарий compensation; +- duplicate delivery / duplicate event handling; +- contract compatibility; +- schema compatibility. + +### Для критичных процессов и high-risk изменений +Покрывать пропорционально риску изменения через automated tests и/или явный resilience test plan: +- перезапуск воркера во время выполнения; +- временную недоступность БД; +- временную недоступность внешней зависимости; +- разрыв связности Kafka / BPM; +- сценарий load / lag recovery. + +--- + +## Validation + +Следуй разделу `Validation commands` из `AGENTS.md`. + +Если для изменения есть module-specific команды, используй минимальный релевантный прогон, а затем более широкий, когда это необходимо. + +Если какая-либо команда не была запущена: +- точно укажи, какая именно команда не запускалась; +- объясни почему; +- опиши остаточный риск. diff --git a/docs/standards/PLATFORM_STANDARD.md b/docs/standards/PLATFORM_STANDARD.md new file mode 100644 index 0000000..1ae85be --- /dev/null +++ b/docs/standards/PLATFORM_STANDARD.md @@ -0,0 +1,303 @@ +# PLATFORM_STANDARD.md + +## Назначение + +Этот документ задаёт платформенные требования для сервисов, работающих в BPM / Kafka / Zeebe-среде. + +Документ определяет: +- архитектурные инварианты; +- требования к идемпотентности и надёжности; +- правила BPM / Kafka интеграции; +- требования к observability, compatibility, retry / DLQ, compensation и rollout safety. + +Это **внутренний платформенный стандарт**. Некоторые правила ниже являются осознанной инженерной политикой команды и не должны трактоваться как универсальная истина для всей отрасли. + +--- + +## Архитектурные инварианты + +- BPM — только оркестратор процесса. +- Доменное состояние и бизнес-правила принадлежат сервису, а не BPM. +- BPM не должен использоваться как master data store для доменных данных. +- BPM может хранить только идентификаторы, ссылки и технические метаданные, необходимые для оркестрации. +- Везде предполагается at-least-once доставка. Повторная доставка — норма, а не исключение. +- Exactly-once не предполагается; корректность достигается через идемпотентность, dedup, Outbox и управляемые retries. +- Каждый обработчик обязан быть идемпотентным. + +--- + +## Правила использования BPM + +- BPM использовать только для операторских / регламентных процессов и long-running orchestration. +- Не использовать BPM для высокочастотных near-real-time streaming-потоков. +- Каждый BPM service task должен представлять ровно одну атомарную доменную операцию. +- Логика ожидания, таймаутов, ретраев, эскалаций и компенсаций должна моделироваться явно в BPM, а не быть скрыта внутри сервиса. +- BPMN flow не должен становиться местом владения бизнес-логикой. + +--- + +## Camunda 8 / Zeebe + +### Граница интеграции + +- BPM-to-service-task интеграция должна идти через Zeebe gRPC Job Worker. +- Для нашей платформы использование REST connector для service task считается **не рекомендуемым и по умолчанию запрещённым**, если нет отдельного архитектурного согласования. +- Нельзя изменять `jobType`, пока существуют активные инстансы процесса. + +### Обязательные поля корреляции + +Каждый service task обязан принимать / обрабатывать: +- `businessKey` +- `operationId` +- `correlationId` +- `processInstanceKey` +- `traceId` или `traceparent` + +### Контракт воркера + +Для каждого Zeebe worker: +- читать входные данные только из process / job variables; +- валидировать обязательные поля корреляции до начала обработки; +- проверять dedup store по `operationId` до выполнения side effects; +- возвращать семантические результаты: + - `SUCCESS` -> `CompleteJob` + - `RETRYABLE_ERROR` -> `FailJob` с retry / backoff + - `BUSINESS_ERROR` -> `ThrowError` + - `FATAL_ERROR` -> ветка инцидента или обработка через исчерпание retries +- никогда не генерировать `operationId` внутри сервиса для входящих оркестрируемых вызовов; +- вызывать `CompleteJob` только после commit доменной транзакции. + +--- + +## Идемпотентность + +Все операции, вызываемые из BPM, и все Kafka consumers обязаны быть идемпотентными. + +Обязательные правила: +- `operationId` должен приходить от вызывающей стороны / оркестратора; +- dedup storage должен обеспечивать atomic check-and-write semantics; +- dedup storage должен защищать от race conditions; +- повторный вызов с тем же `operationId` должен возвращать тот же семантический результат и не должен повторять side effects; +- для записей dedup должен быть документирован TTL в соответствии с бизнес-окном возможного повтора; +- non-atomic реализация идемпотентности запрещена. + +Дополнительное обязательное правило: +- повторный вызов с тем же `operationId`, но с другим `businessKey` или семантически иным payload / параметрами, считается конфликтом контракта, а не валидным retry; такой случай должен приводить к явной ошибке / инциденту и быть достаточно залогирован для расследования. + +Рекомендуемая реализация: +- таблица в доменной БД, например `idempotency_keys(operation_id, business_key, status, result_hash, created_at)`. + +--- + +## Порядок транзакции и публикации событий + +Когда операция изменяет доменное состояние и публикует события: + +1. Выполнить доменную операцию. +2. Записать событие(я) в outbox в рамках той же транзакции. +3. Сделать commit транзакции. +4. Только после этого подтверждать BPM (`CompleteJob`) или внешний success. + +Публикация событий вне Outbox запрещена. + +Это правило одинаково применяется к: +- синхронным сервисным операциям; +- Zeebe workers; +- Kafka consumers, которые меняют состояние и публикуют новые события. + +--- + +## Process variables + +- Process variables должны быть минимальными. +- Хранить только ID, ссылки, routing data и небольшие технические метаданные. +- Не хранить файлы, большие JSON payload, большие массивы в process variables. +- Большие payload должны храниться в доменной БД или object storage; в process variables должны храниться только ссылки. +- Process variables должны быть версионированы и документированы. +- Для long-running процессов изменения набора или формы variables должны оставаться совместимыми с уже запущенными инстансами, либо сопровождаться явной migration strategy. +- Для нашей платформы безопасным внутренним ориентиром считается бюджет **50–100 KB на один process instance**. Это не технический максимум платформы, а engineering guardrail для управляемости и надёжности процессов. + +--- + +## Семантика результатов + +Каждая операция должна возвращать семантическую классификацию результата, а не только transport status codes. + +Допустимые классы результатов: +- `SUCCESS` +- `RETRYABLE_ERROR` +- `BUSINESS_ERROR` +- `FATAL_ERROR` + +Каждая long-running операция должна иметь: +- явно заданный SLA / timeout; +- async processing model, где это нужно; +- status lookup по `operationId`; +- определённое поведение cancel, если отмена поддерживается. + +--- + +## Kafka / event-driven + +### Базовые правила + +- Базовое предположение по доставке — at-least-once. +- Consumers должны быть идемпотентными по `event_id` и / или `operation_id`. +- Нельзя размещать бизнес-логику напрямую в consumer / listener классах. +- Consumers / listeners должны делегировать в application / domain services. +- Сохранять текущую retry / DLQ-стратегию, если явно не запрошено её изменение. + +### Семантика `event_id` и `operation_id` + +- `event_id` используется для dedup повторной доставки **одного и того же опубликованного события**. +- `operation_id` используется для dedup повторного выполнения **одной и той же бизнес-операции**, если этот идентификатор протягивается через события и обработчики. +- Если в сообщении присутствуют оба идентификатора, обработчик обязан явно понимать и документировать, какой из них используется для какой семантики dedup. + +### Контракты событий + +Требования ниже относятся прежде всего к **domain events**. Для технических, retry, DLQ, compacted state и других служебных топиков может использоваться отдельная документированная конвенция. + +Для domain events: +- именование топиков должно следовать шаблону `...v`; +- partition key должен сохранять порядок событий по одной сущности через `business_key`; +- каждое событие должно содержать как минимум: + - `event_id` + - `event_type` + - `event_version` + - `occurred_at` + - `producer` + - `business_key` + - `correlation_id` + - `trace_id`, если доступен + - `process_instance_id`, если применимо +- предпочтительны Avro / Protobuf со Schema Registry; +- строго валидируемая JSON Schema допустима только если это согласовано платформой. + +### Что нельзя менять без явного запроса + +- topic names +- event schema +- key semantics +- partition assumptions +- serialization format +- retry topology +- DLQ routing rules + +--- + +## Schema evolution и совместимость + +- Breaking changes требуют новой major-версии. +- Добавление optional fields с default значениями допустимо. +- Удаление или переименование полей требует новой major-версии. +- По умолчанию consumers должны оставаться совместимыми как минимум с текущей схемой и согласованной политикой совместимости платформы. Если для конкретного домена зафиксировано правило поддержки текущей и предыдущей minor-версии, оно должно быть явно указано в контракте этого домена. + +Для BPM, messaging и public APIs: +- сохранять backward compatibility, если явно не запрошен breaking change; +- учитывать rolling deployment compatibility; +- учитывать coexistence старых и новых consumers; +- избегать изменений, требующих lockstep deployment, если это явно не запрошено. + +--- + +## Retry / DLQ + +Retries должны быть управляемыми и конечными. + +Рекомендуемая политика: +- `RETRYABLE_ERROR`: ограниченные retries с exponential backoff и jitter +- `BUSINESS_ERROR`: никаких слепых retries; отправка в operator handling / parking lot / DLQ +- `FATAL_ERROR`: никаких слепых retries; создание инцидента и маршрутизация в DLQ, где применимо + +Обязательные правила: +- бесконечные retries запрещены; +- отключение DLQ для критичных consumer flows запрещено; +- replay из DLQ без установленной причины запрещён; +- Zeebe retries и Kafka retries должны рассматриваться как два независимых контура управления. + +--- + +## Compensation + +Если операция имеет side effects, должно быть явно определено одно из следующего: +- compensating operation; +- явное указание, что компенсация невозможна; +- ветка operator / manual remediation. + +Компенсация должна быть: +- идемпотентной; +- детерминированной; +- семантически классифицированной; +- залогированной с `businessKey`, `operationId`, причиной и результатом. + +--- + +## Observability + +Структурированные JSON-логи обязательны. + +Каждый log / event / span должен содержать, где применимо: +- `correlation_id` +- `trace_id` +- `process_instance_id` +- `business_key` +- `operation_id` +- `service_name` +- `env` + +Обязательная телеметрия: +- latency (`p50/p95/p99`) +- error rate по семантическим классам +- retry rate / attempts histogram +- active process instances / stuck instances +- consumer lag / DLQ rate +- compensation rate + +Трассировка: +- OpenTelemetry обязателен +- каждый шаг процесса должен создавать span +- span attributes должны содержать `process_instance_id`, `business_key`, `operation_id`, `step_name` + +Если меняется Kafka / BPM / external integration logic: +- сохранить или добавить correlation identifiers; +- сохранить или добавить `operation_id`, где это релевантно; +- логировать достаточно для дебага retries, duplicates и compensations; +- никогда не логировать секреты или sensitive payloads. + +--- + +## Backpressure и runtime load + +- Воркеры должны явно ограничивать `maxJobsActive` и concurrency. +- Нельзя использовать рост timeout или retry как механизм масштабирования. +- Backpressure должен управляться через масштабирование воркеров и управление пропускной способностью. +- Предположения о throughput воркеров должны быть документированы. +- CPU / RAM saturation должна наблюдаться и иметь алерты. + +--- + +## Безопасность и аудит + +- Сервисы должны поддерживать JWT / OAuth2 там, где это применимо. +- Административные операции должны быть защищены role-based или attribute-based access control. +- Секреты нельзя хранить в репозитории. +- Административные действия, такие как retry / cancel / compensate / migrate, должны быть аудитируемыми. + +--- + +## CI / CD и production safety + +Минимально обязательные гейты **для изменений, где это релевантно**: +- unit tests +- coverage threshold +- static analysis +- API compatibility checks +- schema compatibility checks +- BPM + worker integration tests +- resilience tests для поведения timeout / retry / DLQ + +Правила деплоя: +- деплой BPMN только через pipeline; +- деплой воркеров только через pipeline; +- ручной деплой BPMN в production запрещён; +- критичные изменения retry / timeout / jobType не должны вноситься только через UI без трассируемости в VCS. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f6a971f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,38 @@ +# VERSION: 0.0.3 + +### Versions ### +versions.java=17 +# plugins +versions_kotlin=2.2.20 +versions_spring_boot=4.0.2 +versions_spring_dependency=1.1.7 +versions_docker_remote_api=9.4.0 +versions_sonarqube=5.1.0.4882 +versions_buildtools=0.9.28 +# libraries +versions.spring.cloud=2025.1.1 +versions.testcontainers=1.18.1 +versions.otel=1.12.1 +versions.lombok=1.18.36 +versions.hibernate.spatial=6.5.1.Final +versions.camunda=8.4.15 +versions.open-api=3.0.1 +versions.spring.boot.admin=4.0.1 + +# without spring +versions.jackson_databind=2.18.2 +versions.logback=1.5.13 +versions.slf4j=2.0.16 +versions.junit=5.11.4 +versions.mockito=5.14.2 + + +versions.classgraph=4.8.168 + +### Dependencies ### +dep.spring.actuator=org.springframework.boot:spring-boot-starter-actuator +dep.lombok=org.projectlombok:lombok:${versions.lombok} + +### Settings ### +sonar.qualitygate.wait=true +sonar.host.url=https://qube.nstest.local diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b2f5206 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/helm/pcp-ballistics-service/Chart.yaml b/helm/pcp-ballistics-service/Chart.yaml new file mode 100644 index 0000000..035ef85 --- /dev/null +++ b/helm/pcp-ballistics-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-ballistics-service +description: Pilot Helm chart for deploying pcp-ballistics-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-ballistics-service/templates/_helpers.tpl b/helm/pcp-ballistics-service/templates/_helpers.tpl new file mode 100644 index 0000000..abf3de5 --- /dev/null +++ b/helm/pcp-ballistics-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-ballistics-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-ballistics-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-ballistics-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-ballistics-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-ballistics-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-ballistics-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-ballistics-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-ballistics-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-ballistics-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-ballistics-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-ballistics-service/templates/deployment.yaml b/helm/pcp-ballistics-service/templates/deployment.yaml new file mode 100644 index 0000000..ff77bab --- /dev/null +++ b/helm/pcp-ballistics-service/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-ballistics-service.fullname" . }} + labels: + {{- include "pcp-ballistics-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-ballistics-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-ballistics-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-ballistics-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-ballistics-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-ballistics-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-ballistics-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-ballistics-service/templates/ingress.yaml b/helm/pcp-ballistics-service/templates/ingress.yaml new file mode 100644 index 0000000..7cedffb --- /dev/null +++ b/helm/pcp-ballistics-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-ballistics-service.fullname" . }} + labels: + {{- include "pcp-ballistics-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-ballistics-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-ballistics-service/templates/service.yaml b/helm/pcp-ballistics-service/templates/service.yaml new file mode 100644 index 0000000..ba3354c --- /dev/null +++ b/helm/pcp-ballistics-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-ballistics-service.fullname" . }} + labels: + {{- include "pcp-ballistics-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-ballistics-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-ballistics-service/values-dev.yaml b/helm/pcp-ballistics-service/values-dev.yaml new file mode 100644 index 0000000..c10c9a1 --- /dev/null +++ b/helm/pcp-ballistics-service/values-dev.yaml @@ -0,0 +1,11 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-ballistics-service.dev.k8s.264.nstart.cloud diff --git a/helm/pcp-ballistics-service/values-master.yaml b/helm/pcp-ballistics-service/values-master.yaml new file mode 100644 index 0000000..667248c --- /dev/null +++ b/helm/pcp-ballistics-service/values-master.yaml @@ -0,0 +1,10 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-ballistics-service.master.k8s.264.nstart.cloud diff --git a/helm/pcp-ballistics-service/values.yaml b/helm/pcp-ballistics-service/values.yaml new file mode 100644 index 0000000..7726dd0 --- /dev/null +++ b/helm/pcp-ballistics-service/values.yaml @@ -0,0 +1,48 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-ballistics-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "250m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-complex-mission-service/Chart.yaml b/helm/pcp-complex-mission-service/Chart.yaml new file mode 100644 index 0000000..ce77336 --- /dev/null +++ b/helm/pcp-complex-mission-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-complex-mission-service +description: Helm chart for deploying pcp-complex-mission-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-complex-mission-service/templates/_helpers.tpl b/helm/pcp-complex-mission-service/templates/_helpers.tpl new file mode 100644 index 0000000..28d0846 --- /dev/null +++ b/helm/pcp-complex-mission-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-complex-mission-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-complex-mission-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-complex-mission-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-complex-mission-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-complex-mission-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-complex-mission-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-complex-mission-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-complex-mission-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-complex-mission-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-complex-mission-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-complex-mission-service/templates/deployment.yaml b/helm/pcp-complex-mission-service/templates/deployment.yaml new file mode 100644 index 0000000..61019f4 --- /dev/null +++ b/helm/pcp-complex-mission-service/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-complex-mission-service.fullname" . }} + labels: + {{- include "pcp-complex-mission-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-complex-mission-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-complex-mission-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-complex-mission-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-complex-mission-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-complex-mission-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-complex-mission-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-complex-mission-service/templates/ingress.yaml b/helm/pcp-complex-mission-service/templates/ingress.yaml new file mode 100644 index 0000000..62e16ca --- /dev/null +++ b/helm/pcp-complex-mission-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-complex-mission-service.fullname" . }} + labels: + {{- include "pcp-complex-mission-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-complex-mission-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-complex-mission-service/templates/service.yaml b/helm/pcp-complex-mission-service/templates/service.yaml new file mode 100644 index 0000000..0f94c46 --- /dev/null +++ b/helm/pcp-complex-mission-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-complex-mission-service.fullname" . }} + labels: + {{- include "pcp-complex-mission-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-complex-mission-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-complex-mission-service/values-dev.yaml b/helm/pcp-complex-mission-service/values-dev.yaml new file mode 100644 index 0000000..272c203 --- /dev/null +++ b/helm/pcp-complex-mission-service/values-dev.yaml @@ -0,0 +1,11 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-complex-mission-service.dev.k8s.264.nstart.cloud diff --git a/helm/pcp-complex-mission-service/values-master.yaml b/helm/pcp-complex-mission-service/values-master.yaml new file mode 100644 index 0000000..3672a69 --- /dev/null +++ b/helm/pcp-complex-mission-service/values-master.yaml @@ -0,0 +1,10 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-complex-mission-service.master.k8s.264.nstart.cloud diff --git a/helm/pcp-complex-mission-service/values.yaml b/helm/pcp-complex-mission-service/values.yaml new file mode 100644 index 0000000..be91216 --- /dev/null +++ b/helm/pcp-complex-mission-service/values.yaml @@ -0,0 +1,48 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-complex-mission-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "500m" + memory: "2Gi" + limits: + cpu: "4" + memory: "4Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-coverage-scheme-service/Chart.yaml b/helm/pcp-coverage-scheme-service/Chart.yaml new file mode 100644 index 0000000..9a159bc --- /dev/null +++ b/helm/pcp-coverage-scheme-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-coverage-scheme-service +description: Helm chart for deploying pcp-coverage-scheme-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-coverage-scheme-service/templates/_helpers.tpl b/helm/pcp-coverage-scheme-service/templates/_helpers.tpl new file mode 100644 index 0000000..d7424f1 --- /dev/null +++ b/helm/pcp-coverage-scheme-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-coverage-scheme-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-coverage-scheme-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-coverage-scheme-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-coverage-scheme-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-coverage-scheme-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-coverage-scheme-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-coverage-scheme-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-coverage-scheme-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-coverage-scheme-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-coverage-scheme-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-coverage-scheme-service/templates/deployment.yaml b/helm/pcp-coverage-scheme-service/templates/deployment.yaml new file mode 100644 index 0000000..21de63a --- /dev/null +++ b/helm/pcp-coverage-scheme-service/templates/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-coverage-scheme-service.fullname" . }} + labels: + {{- include "pcp-coverage-scheme-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-coverage-scheme-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-coverage-scheme-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-coverage-scheme-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-coverage-scheme-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-coverage-scheme-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-coverage-scheme-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-coverage-scheme-service/templates/ingress.yaml b/helm/pcp-coverage-scheme-service/templates/ingress.yaml new file mode 100644 index 0000000..ed05aa8 --- /dev/null +++ b/helm/pcp-coverage-scheme-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-coverage-scheme-service.fullname" . }} + labels: + {{- include "pcp-coverage-scheme-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-coverage-scheme-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-coverage-scheme-service/templates/service.yaml b/helm/pcp-coverage-scheme-service/templates/service.yaml new file mode 100644 index 0000000..62d3796 --- /dev/null +++ b/helm/pcp-coverage-scheme-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-coverage-scheme-service.fullname" . }} + labels: + {{- include "pcp-coverage-scheme-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-coverage-scheme-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-coverage-scheme-service/values-dev.yaml b/helm/pcp-coverage-scheme-service/values-dev.yaml new file mode 100644 index 0000000..626979a --- /dev/null +++ b/helm/pcp-coverage-scheme-service/values-dev.yaml @@ -0,0 +1,15 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-coverage-scheme-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-coverage-scheme-service/values-master.yaml b/helm/pcp-coverage-scheme-service/values-master.yaml new file mode 100644 index 0000000..7d43b30 --- /dev/null +++ b/helm/pcp-coverage-scheme-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-coverage-scheme-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-coverage-scheme-service/values.yaml b/helm/pcp-coverage-scheme-service/values.yaml new file mode 100644 index 0000000..dc3f6f6 --- /dev/null +++ b/helm/pcp-coverage-scheme-service/values.yaml @@ -0,0 +1,47 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-coverage-scheme-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1" + memory: "2Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-dynamic-plan-service/Chart.yaml b/helm/pcp-dynamic-plan-service/Chart.yaml new file mode 100644 index 0000000..7e3a512 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-dynamic-plan-service +description: Helm chart for deploying pcp-dynamic-plan-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-dynamic-plan-service/templates/_helpers.tpl b/helm/pcp-dynamic-plan-service/templates/_helpers.tpl new file mode 100644 index 0000000..bd13066 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-dynamic-plan-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-dynamic-plan-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-dynamic-plan-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-dynamic-plan-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-dynamic-plan-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-dynamic-plan-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-dynamic-plan-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-dynamic-plan-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-dynamic-plan-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-dynamic-plan-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-dynamic-plan-service/templates/deployment.yaml b/helm/pcp-dynamic-plan-service/templates/deployment.yaml new file mode 100644 index 0000000..04b8ef0 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-dynamic-plan-service.fullname" . }} + labels: + {{- include "pcp-dynamic-plan-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-dynamic-plan-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-dynamic-plan-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-dynamic-plan-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-dynamic-plan-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-dynamic-plan-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-dynamic-plan-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-dynamic-plan-service/templates/ingress.yaml b/helm/pcp-dynamic-plan-service/templates/ingress.yaml new file mode 100644 index 0000000..040d352 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-dynamic-plan-service.fullname" . }} + labels: + {{- include "pcp-dynamic-plan-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-dynamic-plan-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-dynamic-plan-service/templates/service.yaml b/helm/pcp-dynamic-plan-service/templates/service.yaml new file mode 100644 index 0000000..e9171d8 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-dynamic-plan-service.fullname" . }} + labels: + {{- include "pcp-dynamic-plan-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-dynamic-plan-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-dynamic-plan-service/values-dev.yaml b/helm/pcp-dynamic-plan-service/values-dev.yaml new file mode 100644 index 0000000..25656a8 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/values-dev.yaml @@ -0,0 +1,11 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-dynamic-plan-service.dev.k8s.264.nstart.cloud diff --git a/helm/pcp-dynamic-plan-service/values-master.yaml b/helm/pcp-dynamic-plan-service/values-master.yaml new file mode 100644 index 0000000..1b1a4cd --- /dev/null +++ b/helm/pcp-dynamic-plan-service/values-master.yaml @@ -0,0 +1,10 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-dynamic-plan-service.master.k8s.264.nstart.cloud diff --git a/helm/pcp-dynamic-plan-service/values.yaml b/helm/pcp-dynamic-plan-service/values.yaml new file mode 100644 index 0000000..83f9b93 --- /dev/null +++ b/helm/pcp-dynamic-plan-service/values.yaml @@ -0,0 +1,48 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-dynamic-plan-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "250m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-mission-planing-service/Chart.yaml b/helm/pcp-mission-planing-service/Chart.yaml new file mode 100644 index 0000000..1012350 --- /dev/null +++ b/helm/pcp-mission-planing-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-mission-planing-service +description: Helm chart for deploying pcp-mission-planing-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-mission-planing-service/templates/_helpers.tpl b/helm/pcp-mission-planing-service/templates/_helpers.tpl new file mode 100644 index 0000000..c39467d --- /dev/null +++ b/helm/pcp-mission-planing-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-mission-planing-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-mission-planing-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-mission-planing-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-mission-planing-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-mission-planing-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-mission-planing-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-mission-planing-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-mission-planing-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-mission-planing-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-mission-planing-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-mission-planing-service/templates/deployment.yaml b/helm/pcp-mission-planing-service/templates/deployment.yaml new file mode 100644 index 0000000..326b354 --- /dev/null +++ b/helm/pcp-mission-planing-service/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-mission-planing-service.fullname" . }} + labels: + {{- include "pcp-mission-planing-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-mission-planing-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-mission-planing-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-mission-planing-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-mission-planing-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-mission-planing-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-mission-planing-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-mission-planing-service/templates/ingress.yaml b/helm/pcp-mission-planing-service/templates/ingress.yaml new file mode 100644 index 0000000..0412b4d --- /dev/null +++ b/helm/pcp-mission-planing-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-mission-planing-service.fullname" . }} + labels: + {{- include "pcp-mission-planing-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-mission-planing-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-mission-planing-service/templates/service.yaml b/helm/pcp-mission-planing-service/templates/service.yaml new file mode 100644 index 0000000..eb5bf17 --- /dev/null +++ b/helm/pcp-mission-planing-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-mission-planing-service.fullname" . }} + labels: + {{- include "pcp-mission-planing-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-mission-planing-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-mission-planing-service/values-dev.yaml b/helm/pcp-mission-planing-service/values-dev.yaml new file mode 100644 index 0000000..f9729c4 --- /dev/null +++ b/helm/pcp-mission-planing-service/values-dev.yaml @@ -0,0 +1,15 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-mission-planing-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-mission-planing-service/values-master.yaml b/helm/pcp-mission-planing-service/values-master.yaml new file mode 100644 index 0000000..39e1526 --- /dev/null +++ b/helm/pcp-mission-planing-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-mission-planing-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-mission-planing-service/values.yaml b/helm/pcp-mission-planing-service/values.yaml new file mode 100644 index 0000000..dd954bf --- /dev/null +++ b/helm/pcp-mission-planing-service/values.yaml @@ -0,0 +1,48 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-mission-planing-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "2Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-request-service/Chart.yaml b/helm/pcp-request-service/Chart.yaml new file mode 100644 index 0000000..b3602db --- /dev/null +++ b/helm/pcp-request-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-request-service +description: Helm chart for deploying pcp-request-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-request-service/templates/_helpers.tpl b/helm/pcp-request-service/templates/_helpers.tpl new file mode 100644 index 0000000..8333cc8 --- /dev/null +++ b/helm/pcp-request-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-request-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-request-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-request-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-request-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-request-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-request-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-request-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-request-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-request-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-request-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-request-service/templates/deployment.yaml b/helm/pcp-request-service/templates/deployment.yaml new file mode 100644 index 0000000..7913399 --- /dev/null +++ b/helm/pcp-request-service/templates/deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-request-service.fullname" . }} + labels: + {{- include "pcp-request-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-request-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-request-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-request-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-request-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-request-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-request-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + startupProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-request-service/templates/ingress.yaml b/helm/pcp-request-service/templates/ingress.yaml new file mode 100644 index 0000000..6933cd7 --- /dev/null +++ b/helm/pcp-request-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-request-service.fullname" . }} + labels: + {{- include "pcp-request-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-request-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-request-service/templates/service.yaml b/helm/pcp-request-service/templates/service.yaml new file mode 100644 index 0000000..b350b2e --- /dev/null +++ b/helm/pcp-request-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-request-service.fullname" . }} + labels: + {{- include "pcp-request-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-request-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-request-service/values-dev.yaml b/helm/pcp-request-service/values-dev.yaml new file mode 100644 index 0000000..62d37b1 --- /dev/null +++ b/helm/pcp-request-service/values-dev.yaml @@ -0,0 +1,15 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-request-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-request-service/values-master.yaml b/helm/pcp-request-service/values-master.yaml new file mode 100644 index 0000000..58b8fc8 --- /dev/null +++ b/helm/pcp-request-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-request-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-request-service/values.yaml b/helm/pcp-request-service/values.yaml new file mode 100644 index 0000000..a414891 --- /dev/null +++ b/helm/pcp-request-service/values.yaml @@ -0,0 +1,48 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-request-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "2Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-satellite-catalog-service/Chart.yaml b/helm/pcp-satellite-catalog-service/Chart.yaml new file mode 100644 index 0000000..69bc4da --- /dev/null +++ b/helm/pcp-satellite-catalog-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-satellite-catalog-service +description: Helm chart for deploying pcp-satellite-catalog-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-satellite-catalog-service/templates/_helpers.tpl b/helm/pcp-satellite-catalog-service/templates/_helpers.tpl new file mode 100644 index 0000000..8a9f53f --- /dev/null +++ b/helm/pcp-satellite-catalog-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-satellite-catalog-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-satellite-catalog-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-satellite-catalog-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-satellite-catalog-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-satellite-catalog-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-satellite-catalog-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-satellite-catalog-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-satellite-catalog-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-satellite-catalog-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-satellite-catalog-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-satellite-catalog-service/templates/deployment.yaml b/helm/pcp-satellite-catalog-service/templates/deployment.yaml new file mode 100644 index 0000000..77f9c29 --- /dev/null +++ b/helm/pcp-satellite-catalog-service/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-satellite-catalog-service.fullname" . }} + labels: + {{- include "pcp-satellite-catalog-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-satellite-catalog-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-satellite-catalog-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-satellite-catalog-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-satellite-catalog-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-satellite-catalog-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-satellite-catalog-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-satellite-catalog-service/templates/ingress.yaml b/helm/pcp-satellite-catalog-service/templates/ingress.yaml new file mode 100644 index 0000000..3b24170 --- /dev/null +++ b/helm/pcp-satellite-catalog-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-satellite-catalog-service.fullname" . }} + labels: + {{- include "pcp-satellite-catalog-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-satellite-catalog-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-satellite-catalog-service/templates/service.yaml b/helm/pcp-satellite-catalog-service/templates/service.yaml new file mode 100644 index 0000000..660abf2 --- /dev/null +++ b/helm/pcp-satellite-catalog-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-satellite-catalog-service.fullname" . }} + labels: + {{- include "pcp-satellite-catalog-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-satellite-catalog-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-satellite-catalog-service/values-dev.yaml b/helm/pcp-satellite-catalog-service/values-dev.yaml new file mode 100644 index 0000000..fad05eb --- /dev/null +++ b/helm/pcp-satellite-catalog-service/values-dev.yaml @@ -0,0 +1,27 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-satellite-catalog-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_satellite_catalog + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: pcp-satellite-catalog-db + key: SPRING_DATASOURCE_USERNAME + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: pcp-satellite-catalog-db + key: SPRING_DATASOURCE_PASSWORD diff --git a/helm/pcp-satellite-catalog-service/values-master.yaml b/helm/pcp-satellite-catalog-service/values-master.yaml new file mode 100644 index 0000000..ec4eb55 --- /dev/null +++ b/helm/pcp-satellite-catalog-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-satellite-catalog-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-satellite-catalog-service/values.yaml b/helm/pcp-satellite-catalog-service/values.yaml new file mode 100644 index 0000000..c95f742 --- /dev/null +++ b/helm/pcp-satellite-catalog-service/values.yaml @@ -0,0 +1,42 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-satellite-catalog-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: {} + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-spring-boot-admin-server/Chart.yaml b/helm/pcp-spring-boot-admin-server/Chart.yaml new file mode 100644 index 0000000..dc290ef --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-spring-boot-admin-server +description: Helm chart for deploying pcp-spring-boot-admin-server from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-spring-boot-admin-server/templates/_helpers.tpl b/helm/pcp-spring-boot-admin-server/templates/_helpers.tpl new file mode 100644 index 0000000..90d1c44 --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/templates/_helpers.tpl @@ -0,0 +1,18 @@ +{{- define "pcp-spring-boot-admin-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-spring-boot-admin-server.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-spring-boot-admin-server.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-spring-boot-admin-server.labels" -}} +app.kubernetes.io/name: {{ include "pcp-spring-boot-admin-server.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/helm/pcp-spring-boot-admin-server/templates/deployment.yaml b/helm/pcp-spring-boot-admin-server/templates/deployment.yaml new file mode 100644 index 0000000..38696bc --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/templates/deployment.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-spring-boot-admin-server.fullname" . }} + labels: + {{- include "pcp-spring-boot-admin-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-spring-boot-admin-server.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-spring-boot-admin-server.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-spring-boot-admin-server.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + - name: SERVER_PORT + value: {{ .Values.service.port | quote }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-spring-boot-admin-server/templates/ingress.yaml b/helm/pcp-spring-boot-admin-server/templates/ingress.yaml new file mode 100644 index 0000000..161c57d --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-spring-boot-admin-server.fullname" . }} + labels: + {{- include "pcp-spring-boot-admin-server.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-spring-boot-admin-server.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-spring-boot-admin-server/templates/service.yaml b/helm/pcp-spring-boot-admin-server/templates/service.yaml new file mode 100644 index 0000000..a1e248d --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-spring-boot-admin-server.fullname" . }} + labels: + {{- include "pcp-spring-boot-admin-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-spring-boot-admin-server.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-spring-boot-admin-server/values-dev.yaml b/helm/pcp-spring-boot-admin-server/values-dev.yaml new file mode 100644 index 0000000..8de8cbd --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/values-dev.yaml @@ -0,0 +1,10 @@ +app: + profile: dev + configLabel: dev + +service: + port: 38889 + +ingress: + enabled: true + host: pcp-spring-boot-admin-server.dev.k8s.264.nstart.cloud diff --git a/helm/pcp-spring-boot-admin-server/values.yaml b/helm/pcp-spring-boot-admin-server/values.yaml new file mode 100644 index 0000000..b6a8215 --- /dev/null +++ b/helm/pcp-spring-boot-admin-server/values.yaml @@ -0,0 +1,36 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-spring-boot-admin-server + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 38889 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: {} + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-stations-service/Chart.yaml b/helm/pcp-stations-service/Chart.yaml new file mode 100644 index 0000000..259b32c --- /dev/null +++ b/helm/pcp-stations-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-stations-service +description: Helm chart for deploying pcp-stations-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-stations-service/templates/_helpers.tpl b/helm/pcp-stations-service/templates/_helpers.tpl new file mode 100644 index 0000000..c9012d2 --- /dev/null +++ b/helm/pcp-stations-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-stations-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-stations-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-stations-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-stations-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-stations-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-stations-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-stations-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-stations-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-stations-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-stations-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-stations-service/templates/deployment.yaml b/helm/pcp-stations-service/templates/deployment.yaml new file mode 100644 index 0000000..59616f4 --- /dev/null +++ b/helm/pcp-stations-service/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-stations-service.fullname" . }} + labels: + {{- include "pcp-stations-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-stations-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-stations-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-stations-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-stations-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-stations-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-stations-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-stations-service/templates/ingress.yaml b/helm/pcp-stations-service/templates/ingress.yaml new file mode 100644 index 0000000..c1f5958 --- /dev/null +++ b/helm/pcp-stations-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-stations-service.fullname" . }} + labels: + {{- include "pcp-stations-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-stations-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-stations-service/templates/service.yaml b/helm/pcp-stations-service/templates/service.yaml new file mode 100644 index 0000000..fbc7670 --- /dev/null +++ b/helm/pcp-stations-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-stations-service.fullname" . }} + labels: + {{- include "pcp-stations-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-stations-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-stations-service/values-dev.yaml b/helm/pcp-stations-service/values-dev.yaml new file mode 100644 index 0000000..db9b249 --- /dev/null +++ b/helm/pcp-stations-service/values-dev.yaml @@ -0,0 +1,15 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-stations-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-stations-service/values-master.yaml b/helm/pcp-stations-service/values-master.yaml new file mode 100644 index 0000000..e7d2c82 --- /dev/null +++ b/helm/pcp-stations-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-stations-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-stations-service/values.yaml b/helm/pcp-stations-service/values.yaml new file mode 100644 index 0000000..6928917 --- /dev/null +++ b/helm/pcp-stations-service/values.yaml @@ -0,0 +1,42 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-stations-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: {} + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-tgu-service/Chart.yaml b/helm/pcp-tgu-service/Chart.yaml new file mode 100644 index 0000000..e88e19f --- /dev/null +++ b/helm/pcp-tgu-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-tgu-service +description: Helm chart for deploying pcp-tgu-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-tgu-service/templates/_helpers.tpl b/helm/pcp-tgu-service/templates/_helpers.tpl new file mode 100644 index 0000000..a4aa126 --- /dev/null +++ b/helm/pcp-tgu-service/templates/_helpers.tpl @@ -0,0 +1,18 @@ +{{- define "pcp-tgu-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-tgu-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-tgu-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-tgu-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-tgu-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/helm/pcp-tgu-service/templates/deployment.yaml b/helm/pcp-tgu-service/templates/deployment.yaml new file mode 100644 index 0000000..46f24d7 --- /dev/null +++ b/helm/pcp-tgu-service/templates/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-tgu-service.fullname" . }} + labels: + {{- include "pcp-tgu-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-tgu-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-tgu-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-tgu-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- if .Values.app.serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ .Values.app.serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ .Values.app.managementBaseUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-tgu-service/templates/ingress.yaml b/helm/pcp-tgu-service/templates/ingress.yaml new file mode 100644 index 0000000..84667e5 --- /dev/null +++ b/helm/pcp-tgu-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-tgu-service.fullname" . }} + labels: + {{- include "pcp-tgu-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-tgu-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-tgu-service/templates/service.yaml b/helm/pcp-tgu-service/templates/service.yaml new file mode 100644 index 0000000..bf0190f --- /dev/null +++ b/helm/pcp-tgu-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-tgu-service.fullname" . }} + labels: + {{- include "pcp-tgu-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-tgu-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-tgu-service/values-dev.yaml b/helm/pcp-tgu-service/values-dev.yaml new file mode 100644 index 0000000..7de451a --- /dev/null +++ b/helm/pcp-tgu-service/values-dev.yaml @@ -0,0 +1,17 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + serviceBaseUrl: "http://pcp-tgu-service:8080" + managementBaseUrl: "http://pcp-tgu-service:8080" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-tgu-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-tgu-service/values-master.yaml b/helm/pcp-tgu-service/values-master.yaml new file mode 100644 index 0000000..47e32ac --- /dev/null +++ b/helm/pcp-tgu-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-tgu-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-tgu-service/values.yaml b/helm/pcp-tgu-service/values.yaml new file mode 100644 index 0000000..457e6f0 --- /dev/null +++ b/helm/pcp-tgu-service/values.yaml @@ -0,0 +1,39 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-tgu-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + managementBaseUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: {} + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/pcp-ui-service/Chart.yaml b/helm/pcp-ui-service/Chart.yaml new file mode 100644 index 0000000..0b7bc88 --- /dev/null +++ b/helm/pcp-ui-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-ui-service +description: Helm chart for deploying pcp-ui-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/pcp-ui-service/templates/_helpers.tpl b/helm/pcp-ui-service/templates/_helpers.tpl new file mode 100644 index 0000000..46ac916 --- /dev/null +++ b/helm/pcp-ui-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "pcp-ui-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "pcp-ui-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "pcp-ui-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-ui-service.labels" -}} +app.kubernetes.io/name: {{ include "pcp-ui-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "pcp-ui-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-ui-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "pcp-ui-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "pcp-ui-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "pcp-ui-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/pcp-ui-service/templates/deployment.yaml b/helm/pcp-ui-service/templates/deployment.yaml new file mode 100644 index 0000000..d4b2ce5 --- /dev/null +++ b/helm/pcp-ui-service/templates/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pcp-ui-service.fullname" . }} + labels: + {{- include "pcp-ui-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "pcp-ui-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "pcp-ui-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "pcp-ui-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "pcp-ui-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- $managementBaseUrl := include "pcp-ui-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "pcp-ui-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/pcp-ui-service/templates/ingress.yaml b/helm/pcp-ui-service/templates/ingress.yaml new file mode 100644 index 0000000..852efb4 --- /dev/null +++ b/helm/pcp-ui-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pcp-ui-service.fullname" . }} + labels: + {{- include "pcp-ui-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "pcp-ui-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/pcp-ui-service/templates/service.yaml b/helm/pcp-ui-service/templates/service.yaml new file mode 100644 index 0000000..5d34efc --- /dev/null +++ b/helm/pcp-ui-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pcp-ui-service.fullname" . }} + labels: + {{- include "pcp-ui-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "pcp-ui-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/pcp-ui-service/values-dev.yaml b/helm/pcp-ui-service/values-dev.yaml new file mode 100644 index 0000000..af09cc6 --- /dev/null +++ b/helm/pcp-ui-service/values-dev.yaml @@ -0,0 +1,15 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-ui-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-ui-service/values-master.yaml b/helm/pcp-ui-service/values-master.yaml new file mode 100644 index 0000000..75f7c6e --- /dev/null +++ b/helm/pcp-ui-service/values-master.yaml @@ -0,0 +1,14 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-ui-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" diff --git a/helm/pcp-ui-service/values.yaml b/helm/pcp-ui-service/values.yaml new file mode 100644 index 0000000..b345be3 --- /dev/null +++ b/helm/pcp-ui-service/values.yaml @@ -0,0 +1,47 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-ui-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1" + memory: "2Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/slots-service/Chart.yaml b/helm/slots-service/Chart.yaml new file mode 100644 index 0000000..3fb6f25 --- /dev/null +++ b/helm/slots-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: pcp-slots-service +description: Helm chart for deploying pcp-slots-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/slots-service/templates/_helpers.tpl b/helm/slots-service/templates/_helpers.tpl new file mode 100644 index 0000000..f92236d --- /dev/null +++ b/helm/slots-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "slots-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "slots-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "slots-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "slots-service.labels" -}} +app.kubernetes.io/name: {{ include "slots-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "slots-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "slots-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "slots-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "slots-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "slots-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/slots-service/templates/deployment.yaml b/helm/slots-service/templates/deployment.yaml new file mode 100644 index 0000000..9a6d4cf --- /dev/null +++ b/helm/slots-service/templates/deployment.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "slots-service.fullname" . }} + labels: + {{- include "slots-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + progressDeadlineSeconds: {{ .Values.progressDeadlineSeconds }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "slots-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "slots-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "slots-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "slots-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "slots-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "slots-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.startupProbe.enabled }} + startupProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.startupProbe.periodSeconds }} + timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }} + failureThreshold: {{ .Values.startupProbe.failureThreshold }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/slots-service/templates/ingress.yaml b/helm/slots-service/templates/ingress.yaml new file mode 100644 index 0000000..fd78ce1 --- /dev/null +++ b/helm/slots-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "slots-service.fullname" . }} + labels: + {{- include "slots-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "slots-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/slots-service/templates/service.yaml b/helm/slots-service/templates/service.yaml new file mode 100644 index 0000000..2e30ce0 --- /dev/null +++ b/helm/slots-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "slots-service.fullname" . }} + labels: + {{- include "slots-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "slots-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/slots-service/values-dev.yaml b/helm/slots-service/values-dev.yaml new file mode 100644 index 0000000..1148206 --- /dev/null +++ b/helm/slots-service/values-dev.yaml @@ -0,0 +1,25 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-slots-service.dev.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" + - name: SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE + value: "20" + - name: SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE + value: "0" + - name: SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT + value: "10000" + - name: SPRING_FLYWAY_CONNECT_RETRIES + value: "10" + - name: SPRING_FLYWAY_CONNECT_RETRIES_INTERVAL + value: "5s" diff --git a/helm/slots-service/values-master.yaml b/helm/slots-service/values-master.yaml new file mode 100644 index 0000000..eb39f41 --- /dev/null +++ b/helm/slots-service/values-master.yaml @@ -0,0 +1,24 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-slots-service.master.k8s.264.nstart.cloud + +extraEnv: + - name: SERVER_PORT + value: "8080" + - name: SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE + value: "20" + - name: SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE + value: "0" + - name: SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT + value: "10000" + - name: SPRING_FLYWAY_CONNECT_RETRIES + value: "10" + - name: SPRING_FLYWAY_CONNECT_RETRIES_INTERVAL + value: "5s" diff --git a/helm/slots-service/values.yaml b/helm/slots-service/values.yaml new file mode 100644 index 0000000..053fd01 --- /dev/null +++ b/helm/slots-service/values.yaml @@ -0,0 +1,56 @@ +replicaCount: 1 +progressDeadlineSeconds: 1800 + +image: + repository: registry.example.com/pcp/slots-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +startupProbe: + enabled: true + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 180 + +resources: + requests: + cpu: "1" + memory: "2Gi" + limits: + cpu: "16" + memory: "8Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "pcp-slots-service" +fullnameOverride: "pcp-slots-service" diff --git a/helm/spring-cloud-config-server/Chart.yaml b/helm/spring-cloud-config-server/Chart.yaml new file mode 100644 index 0000000..4c26ad6 --- /dev/null +++ b/helm/spring-cloud-config-server/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: spring-cloud-config-server +description: Helm chart for deploying spring-cloud-config-server from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/spring-cloud-config-server/templates/_helpers.tpl b/helm/spring-cloud-config-server/templates/_helpers.tpl new file mode 100644 index 0000000..2a7c4df --- /dev/null +++ b/helm/spring-cloud-config-server/templates/_helpers.tpl @@ -0,0 +1,18 @@ +{{- define "spring-cloud-config-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spring-cloud-config-server.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "spring-cloud-config-server.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "spring-cloud-config-server.labels" -}} +app.kubernetes.io/name: {{ include "spring-cloud-config-server.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/helm/spring-cloud-config-server/templates/deployment.yaml b/helm/spring-cloud-config-server/templates/deployment.yaml new file mode 100644 index 0000000..343d4b3 --- /dev/null +++ b/helm/spring-cloud-config-server/templates/deployment.yaml @@ -0,0 +1,75 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "spring-cloud-config-server.fullname" . }} + labels: + {{- include "spring-cloud-config-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "spring-cloud-config-server.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "spring-cloud-config-server.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "spring-cloud-config-server.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + - name: SERVER_PORT + value: {{ .Values.service.targetPort | quote }} + - name: CONFIG_GIT_URI + value: {{ .Values.app.configGitUri | quote }} + - name: CONFIG_GIT_USERNAME + value: {{ .Values.app.configGitUsername | quote }} + - name: CONFIG_GIT_DEFAULT_LABEL + value: {{ .Values.app.configGitDefaultLabel | quote }} + - name: CONFIG_GIT_SEARCH_PATHS + value: {{ .Values.app.configGitSearchPaths | quote }} + - name: CONFIG_GIT_SKIP_SSL_VALIDATION + value: {{ .Values.app.configGitSkipSslValidation | quote }} + - name: CONFIG_GIT_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.gitTokenSecret.name | quote }} + key: {{ .Values.gitTokenSecret.key | quote }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/spring-cloud-config-server/templates/ingress.yaml b/helm/spring-cloud-config-server/templates/ingress.yaml new file mode 100644 index 0000000..e19d7a5 --- /dev/null +++ b/helm/spring-cloud-config-server/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "spring-cloud-config-server.fullname" . }} + labels: + {{- include "spring-cloud-config-server.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "spring-cloud-config-server.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/spring-cloud-config-server/templates/service.yaml b/helm/spring-cloud-config-server/templates/service.yaml new file mode 100644 index 0000000..ced0c4e --- /dev/null +++ b/helm/spring-cloud-config-server/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spring-cloud-config-server.fullname" . }} + labels: + {{- include "spring-cloud-config-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "spring-cloud-config-server.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/spring-cloud-config-server/values-dev.yaml b/helm/spring-cloud-config-server/values-dev.yaml new file mode 100644 index 0000000..8644641 --- /dev/null +++ b/helm/spring-cloud-config-server/values-dev.yaml @@ -0,0 +1,14 @@ +app: + configGitUri: "https://git.nstart.cloud/emelianov.an/pcp.git" + configGitUsername: "gitlab+deploy-token-1" + configGitDefaultLabel: "master" + configGitSearchPaths: "config-repo" + configGitSkipSslValidation: "true" + +service: + port: 38888 + targetPort: 8888 + +ingress: + enabled: true + host: spring-cloud-config-server.dev.k8s.264.nstart.cloud diff --git a/helm/spring-cloud-config-server/values.yaml b/helm/spring-cloud-config-server/values.yaml new file mode 100644 index 0000000..778305d --- /dev/null +++ b/helm/spring-cloud-config-server/values.yaml @@ -0,0 +1,42 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/pcp-cloud-config-server + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 38888 + targetPort: 8888 + +app: + configGitUri: "" + configGitUsername: "oauth2" + configGitDefaultLabel: "master" + configGitSearchPaths: "config-repo" + configGitSkipSslValidation: "false" + +gitTokenSecret: + name: spring-cloud-config-server-git + key: token + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: {} + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/helm/tle-monitoring-service/Chart.yaml b/helm/tle-monitoring-service/Chart.yaml new file mode 100644 index 0000000..7e7ecc6 --- /dev/null +++ b/helm/tle-monitoring-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: tle-monitoring-service +description: Helm chart for deploying tle-monitoring-service from GitLab CI/CD. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/helm/tle-monitoring-service/templates/_helpers.tpl b/helm/tle-monitoring-service/templates/_helpers.tpl new file mode 100644 index 0000000..cf9b372 --- /dev/null +++ b/helm/tle-monitoring-service/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{- define "tle-monitoring-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "tle-monitoring-service.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- include "tle-monitoring-service.name" . | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "tle-monitoring-service.labels" -}} +app.kubernetes.io/name: {{ include "tle-monitoring-service.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "tle-monitoring-service.serviceBaseUrl" -}} +{{- if .Values.app.serviceBaseUrl -}} +{{- .Values.app.serviceBaseUrl -}} +{{- else if and .Values.ingress.enabled .Values.ingress.host -}} +{{- $scheme := .Values.app.serviceBaseUrlScheme | default (ternary "https" "http" (gt (len .Values.ingress.tls) 0)) -}} +{{- printf "%s://%s" $scheme .Values.ingress.host -}} +{{- end -}} +{{- end -}} + +{{- define "tle-monitoring-service.managementBaseUrl" -}} +{{- if .Values.app.managementBaseUrl -}} +{{- .Values.app.managementBaseUrl -}} +{{- else -}} +{{- printf "http://%s:%v" (include "tle-monitoring-service.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "tle-monitoring-service.healthUrl" -}} +{{- if .Values.app.healthUrl -}} +{{- .Values.app.healthUrl -}} +{{- else -}} +{{- printf "%s/actuator/health" (include "tle-monitoring-service.managementBaseUrl" .) -}} +{{- end -}} +{{- end -}} diff --git a/helm/tle-monitoring-service/templates/deployment.yaml b/helm/tle-monitoring-service/templates/deployment.yaml new file mode 100644 index 0000000..295468d --- /dev/null +++ b/helm/tle-monitoring-service/templates/deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "tle-monitoring-service.fullname" . }} + labels: + {{- include "tle-monitoring-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "tle-monitoring-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + {{- include "tle-monitoring-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + containers: + - name: {{ include "tle-monitoring-service.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.app.profile | quote }} + - name: CONFIG_SERVER_URI + value: {{ .Values.app.configServerUri | quote }} + - name: SPRING_CLOUD_CONFIG_LABEL + value: {{ .Values.app.configLabel | quote }} + - name: CONFIG_SERVER_FAIL_FAST + value: {{ .Values.app.configServerFailFast | quote }} + {{- if .Values.app.adminClientUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_URL + value: {{ .Values.app.adminClientUrl | quote }} + {{- end }} + {{- $serviceBaseUrl := include "tle-monitoring-service.serviceBaseUrl" . }} + {{- if $serviceBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_BASE_URL + value: {{ $serviceBaseUrl | quote }} + {{- end }} + {{- if .Values.app.servicePath }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_SERVICE_PATH + value: {{ .Values.app.servicePath | quote }} + {{- end }} + {{- $managementBaseUrl := include "tle-monitoring-service.managementBaseUrl" . }} + {{- if $managementBaseUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_MANAGEMENT_BASE_URL + value: {{ $managementBaseUrl | quote }} + {{- end }} + {{- $healthUrl := include "tle-monitoring-service.healthUrl" . }} + {{- if $healthUrl }} + - name: SPRING_BOOT_ADMIN_CLIENT_INSTANCE_HEALTH_URL + value: {{ $healthUrl | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + startupProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 40 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm/tle-monitoring-service/templates/ingress.yaml b/helm/tle-monitoring-service/templates/ingress.yaml new file mode 100644 index 0000000..867e843 --- /dev/null +++ b/helm/tle-monitoring-service/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "tle-monitoring-service.fullname" . }} + labels: + {{- include "tle-monitoring-service.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "tle-monitoring-service.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/tle-monitoring-service/templates/service.yaml b/helm/tle-monitoring-service/templates/service.yaml new file mode 100644 index 0000000..dc015a8 --- /dev/null +++ b/helm/tle-monitoring-service/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "tle-monitoring-service.fullname" . }} + labels: + {{- include "tle-monitoring-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "tle-monitoring-service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/helm/tle-monitoring-service/values-dev.yaml b/helm/tle-monitoring-service/values-dev.yaml new file mode 100644 index 0000000..9c13074 --- /dev/null +++ b/helm/tle-monitoring-service/values-dev.yaml @@ -0,0 +1,11 @@ +app: + profile: dev + configLabel: dev + adminClientUrl: "http://pcp-spring-boot-admin-server:38889" + +service: + port: 8080 + +ingress: + enabled: true + host: tle-monitoring-service.dev.k8s.264.nstart.cloud diff --git a/helm/tle-monitoring-service/values-master.yaml b/helm/tle-monitoring-service/values-master.yaml new file mode 100644 index 0000000..e2da8bf --- /dev/null +++ b/helm/tle-monitoring-service/values-master.yaml @@ -0,0 +1,10 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: tle-monitoring-service.master.k8s.264.nstart.cloud diff --git a/helm/tle-monitoring-service/values.yaml b/helm/tle-monitoring-service/values.yaml new file mode 100644 index 0000000..cfc9913 --- /dev/null +++ b/helm/tle-monitoring-service/values.yaml @@ -0,0 +1,48 @@ +replicaCount: 1 + +image: + repository: registry.example.com/pcp/tle-monitoring-service + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +service: + type: ClusterIP + port: 8080 + +app: + profile: master + configServerUri: "http://spring-cloud-config-server:38888" + configServerFailFast: "true" + configLabel: master + adminClientUrl: "" + serviceBaseUrl: "" + serviceBaseUrlScheme: "" + servicePath: "/swagger/ui" + managementBaseUrl: "" + healthUrl: "" + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: [] + +extraEnv: [] + +resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1" + memory: "2Gi" + +podAnnotations: {} +podLabels: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/libs/ballistics-lib/build.gradle.kts b/libs/ballistics-lib/build.gradle.kts new file mode 100644 index 0000000..4ddd8eb --- /dev/null +++ b/libs/ballistics-lib/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +group = "space.nstart.pcp" +version = "1.0.0" + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { +// implementation(project(":libs:java-lib")) +// implementation(project(":libs:kotlin-lib")) + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + + implementation("io.github.classgraph:classgraph:${property("versions.classgraph")}") + + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.locationtech.jts:jts-core:1.19.0") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + implementation("io.projectreactor:reactor-core:3.6.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.named("bootJar") { + enabled = false +} + +tasks.named("jar") { + enabled = true +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/Ballistics.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/Ballistics.kt new file mode 100644 index 0000000..61a95b8 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/Ballistics.kt @@ -0,0 +1,368 @@ +package ballistics + +import ballistics.flightLine.FlightLineCalculator +import ballistics.mpl.MPLCalculator +import ballistics.orbitalPoints.AbstractOrbPointsCalculator +import ballistics.orbitalPoints.OrbitalPointsIntegrator +import ballistics.orbitalPoints.OrbitalPointsTLE +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.orbitalPoints.timeStepper.TLEStepper +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.FleghtLineSector +import ballistics.types.FlightLine +import ballistics.types.InitialConditions +import ballistics.types.IntegrationType +import ballistics.types.KeplerParams +import ballistics.types.ModDVType +import ballistics.types.OPKatObj +import ballistics.types.OrbitalPoint +import ballistics.types.PPI +import ballistics.types.PointViewParams +import ballistics.types.RevolutionParameter +import ballistics.types.TLE +import ballistics.types.TLEParams +import ballistics.types.TangageType +import ballistics.types.WorkCSType +import ballistics.types.ZRV +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.toDateTime +import ballistics.zrv.ZRVStepperCalculator +import ballistics.zrv.ZRVStepperCalculatorAsync +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import reactor.core.publisher.Flux +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.sin +import kotlin.math.sqrt + +class Ballistics { + var kaId: Int = -1 + + var kaUmn: Int = -1 + + val ic : MutableList = mutableListOf() + + /** + * Модель движения + */ + var modDVType: ModDVType = ModDVType.FOTO + + /** + * Метод интегрирования + */ + var integrationType: IntegrationType = IntegrationType.ADAMS7 + + /** + * Тип модели Земли + */ + var earthType: EarthType = EarthType.PZ90d02 + + /** + * тип базовой системы координат (для расчета трасс полета, полособзора и матрицы планирования) + */ + var workCoordinateSystem: WorkCSType = WorkCSType.WCSOrbit + + /** + * Минимальный угол крена (для расчета полосы обзора) + */ + var rollMin: Double = 0.0 + + /** + * Максимальный угол крена (для расчета полосы обзора и покрытия земной поверхности) + */ + var rollMax: Double = PI / 6.0 + + + var rollByModule : Boolean = true + + /** + * Граничный угол Солнца для КА (для определения попадания момента наблюдения объекта в матрицу планирования) + */ + var sunAngleMin: Double = 0.0 + + /** + * Признак использования ограничений на попадание в матрицу планирования из параметров объекта + */ + var useObjConstraints: Boolean = false + + /** + * Объект класса расчета точек орбиты + */ + private var orbitCalculator: AbstractOrbPointsCalculator? = null + + /** + * Объект класса расчета параметров ЗРВ + */ + private var zrvCalculator: ZRVStepperCalculator? = null + + /** + * Объект класса расчета параметров трассы полета и полосы обзора + */ + private var flightLineCalculator: FlightLineCalculator? = null + + /** + * Объект класса расчета матрицы планирования + */ + private var mplCalculator: MPLCalculator? = null + + /** + * Массив точек орбиты + */ + val points: Iterable + get() = if (orbitCalculator != null) orbitCalculator!!.points else listOf() + + val revolutions: Iterable + get() = if (orbitCalculator != null) orbitCalculator!!.revolutions else listOf() + + /** + * Массив результатов расчета параметров ЗРВ + */ + val zrv: Iterable + get() = if (zrvCalculator != null) zrvCalculator!!.zrv else listOf() + + val flightLine: Iterable + get() = if (flightLineCalculator != null) flightLineCalculator!!.flightLine else listOf() + + val mpl: Iterable + get() = if (mplCalculator != null) mplCalculator!!.mpl else listOf() + + val coverings : Map> + get() = flightLineCalculator?.earthCoveringCalculator?.coverings?:mapOf() + + + /** + * Расчет точек орбиты методом интегрирования + * @param nu начальные условия движения центра масс КА + * @param tn время начала расчета + * @param tk время конца расчета + * @return код завершения расчета + */ + fun calculateOrbPoints( + nu: InitialConditions, + tn: Double, + tk: Double, + ): BallisticsError { + orbitCalculator = OrbitalPointsIntegrator(nu, modDVType, integrationType, earthType) + return orbitCalculator!!.calculate(tn, tk) + } + + fun setOrbitalPoints( + points: Iterable, + earthType: EarthType = EarthType.PZ90d02, + ) { + orbitCalculator = OrbitalPointsIntegrator(arrayOf(), ModDVType.FOTO, IntegrationType.ADAMS7, earthType) + orbitCalculator?.points?.addAll(points) + } + + + fun setEarthCoverage(covs : Map>){ + if (orbitCalculator != null){ + flightLineCalculator = FlightLineCalculator(orbitCalculator!!, rollMin, rollMax, workCoordinateSystem) + flightLineCalculator?.earthCoveringCalculator?.coverings?.putAll(covs) + } + } + + /** + * Расчет точек орбиты методом интегрирования + * @param nu массив начальных условий движения центра масс КА + * @param tn время начала расчета + * @param tk время конца расчета + * @return код завершения расчета + */ + fun calculateOrbPoints( + nu: Array, + tn: Double, + tk: Double, + ): BallisticsError { + nu.sortBy { it.point.t } + + orbitCalculator = OrbitalPointsIntegrator(nu, modDVType, integrationType, earthType) + + return orbitCalculator!!.calculate(tn, tk) + } + + /** + * Расчет параметров ЗРВ + * @param ppi список ППИ + * @param tn время начала расчета + * @param tk время конца расчета + * @return код завершения расчета + */ + fun calculateZRV( + ppi: List, + tn: Double, + tk: Double, + ): BallisticsError { + return orbitCalculator?.let { + zrvCalculator = ZRVStepperCalculator(it, ppi) + zrvCalculator?.calculate(tn, tk) + } ?: BallisticsError.EMPTY_ORBITAL_POINTS + } + + fun calculateZRVAsync( + ppi: List, + tn: Double, + tk: Double, + ): Flux = + if (orbitCalculator != null) { + ZRVStepperCalculatorAsync(orbitCalculator!!, ppi).calculate(tn, tk) + } else { + Flux.empty() + } + + fun calculateFlightLine( + tn: Double, + tk: Double, + ): BallisticsError { + return orbitCalculator?.let { + flightLineCalculator = FlightLineCalculator(it, rollMin, rollMax, workCoordinateSystem) + flightLineCalculator?.calculate(tn, tk) + } ?: BallisticsError.EMPTY_ORBITAL_POINTS + } + + fun calculateMPL( + tn: Double, + tk: Double, + objs: Iterable, + tangage: Double = 0.0, + tangageType: TangageType = TangageType.TTProactive, + ): BallisticsError { + return flightLineCalculator?.let { + mplCalculator = + MPLCalculator(it, tangage, tangageType).apply { + this.sunAngleMin = this@Ballistics.sunAngleMin + this.useObjConstraints = this@Ballistics.useObjConstraints + } + mplCalculator?.krenMax = rollMax + mplCalculator?.krenMin = rollMin + mplCalculator?.krenByModule = rollByModule + mplCalculator?.calculate(tn, tk, objs) + } ?: BallisticsError.EMPTY_FLIGHTLINE_POINTS + } + + /** + * Объект расчета выхода на заданное время + */ + fun getStepper(): AbstractStepper? { + return orbitCalculator?.getStepper() + } + + fun parseTLE( + tle1: String, + tle2: String, + ): OrbitalPoint { + try { + val stepper = TLEStepper(tle1, tle2, EarthType.PZ90d02) + return stepper.calculate(stepper.baseEpoch) + }catch (ex : Exception){ + throw ex + } + } + + fun parseTLE(tle: TLE): OrbitalPoint { + val stepper = TLEStepper(tle.tle1, tle.tle2, EarthType.PZ90d02) + return stepper.calculate(stepper.baseEpoch) + } + + fun getTLEParams(tle : TLE, capt : String = "") : TLEParams{ + try { + val stepper = TLEStepper(tle.tle1, tle.tle2, EarthType.PZ90d02) + val orb = stepper.calculate(stepper.baseEpoch) + return TLEParams( + capt, stepper.satellite.orbit.satId.toLong(), + stepper.satellite.orbit.orbitNum.toLong(), + toDateTime(orb.t), + stepper.satellite.orbit.inclination, + stepper.satellite.orbit.perigee, + stepper.satellite.orbit.apogee, + stepper.satellite.orbit.argPerigee, + stepper.satellite.orbit.eccentricity, + stepper.satellite.orbit.major, + stepper.satellite.orbit.minor, + stepper.satellite.orbit.meanAnomaly, + stepper.satellite.orbit.period, + stepper.satellite.orbit.semiMajor, + stepper.satellite.orbit.semiMinor, + stepper.satellite.orbit.raan, + stepper.satellite.orbit.meanMotion, + stepper.satellite.orbit.meanMotionTle, + ) + }catch (ex : Exception){ + throw ex + } + } + + fun calculateOrbPoints( + tle: TLE, + tn: Double, + tk: Double, + ): BallisticsError { + orbitCalculator = OrbitalPointsTLE(tle.tle1, tle.tle2, earthType) + return orbitCalculator!!.calculate(tn, tk) + } + + fun calculateOrbPoints( + tle: TLE, + duration: Double, + ): BallisticsError { + orbitCalculator = OrbitalPointsTLE(tle.tle1, tle.tle2, earthType) + val tnu = (orbitCalculator as OrbitalPointsTLE).stepper.baseEpoch + return orbitCalculator!!.calculate(tnu, tnu + duration) + } + + fun calculateKeplerParams(point: OrbitalPoint): KeplerParams { + val res = KeplerParams() + + val aL00 = 6.25648106E+7 + val astro = AstronomerJ2000(earthType) + val ask = astro.grinvToASK(point) + + val r = sqrt(sqr(ask.r.x) + sqr(ask.r.y) + sqr(ask.r.z)) + val v = sqrt(sqr(ask.v.x) + sqr(ask.v.y) + sqr(ask.v.z)) + + val mu = astro.earth.fM + val k = (r * sqr(v)) / mu + + val sinO = (ask.r.x * ask.v.x + ask.r.y * ask.v.y + ask.r.z * ask.v.z) / (r * v) + val cosO = sqrt(1 - sqr(sinO)) + + val c1 = ask.r.y * ask.v.z - ask.r.z * ask.v.y + val c2 = ask.r.z * ask.v.x - ask.r.x * ask.v.z + val c3 = ask.r.x * ask.v.y - ask.r.y * ask.v.x + val c = sqrt(sqr(c1) + sqr(c2) + sqr(c3)) + + res.ael = r / (2 - k) + val lambda = (1 / res.ael) * sqrt(mu / res.ael) + + res.t = (2 * PI * res.ael * sqrt(res.ael)) / sqrt(astro.earth.middleRadius * aL00) + res.e = sqrt(1 - k * (2 - k) * (1 - sinO * sinO)) + res.v = atan((k * sinO * cosO) / (k * sqr(cosO) - 1)) + res.u = atan2((ask.r.z * c), (ask.r.y * c1 - ask.r.x * c2)) + res.eA = atan((sqrt(1 - sqr(res.e)) * k * sinO * cosO) / (sqr(res.e) + (k * sqr(cosO) - 1))) + res.tau = ask.t + ((res.e * sin(res.eA) - res.eA) / lambda) + res.nakl = 0.5 * PI - asin(c3 / c) + res.omegab = atan2(c1, -c2) + res.omegam = res.u - res.v + res.o = asin(sinO) + res.rA = res.ael * (1 + res.e) + res.rP = res.ael * (1 - res.e) + res.dmv = point.t + + if (res.omegam < 0) res.omegam += 2 * PI + if (res.tau < 0) res.tau += 1 + + return res + } + + + fun clear(){ + orbitCalculator?.clear() + flightLineCalculator?.clear() + flightLineCalculator?.clear() + zrvCalculator?.clear() + mplCalculator?.clear() + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/EarthCovering.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/EarthCovering.kt new file mode 100644 index 0000000..c477327 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/EarthCovering.kt @@ -0,0 +1,105 @@ +package ballistics.flightLine + +import ballistics.types.FleghtLineSector +import ballistics.types.FlightLine +import kotlin.math.PI +import kotlin.math.truncate + +internal class EarthCovering { + var coverings = mutableMapOf>() + + fun getBInd(b: Double): Int { + var b2: Double = b * 180.0 / PI + b2 = 90.0 - b2 + var k: Int = truncate(b2 / 2.0).toInt() + if (k > 89) k = 89 + return k + } + + fun getLInd(l2: Double): Int { + var l: Double = l2 * 180.0 / PI + if (l < 0) l += 360.0 + var k: Int = truncate(l / 2.0).toInt() + if (k > 179) k = 179 + return k + } + + fun getInd( + b: Double, + l: Double, + ): Int { + return getBInd(b) * 180 + getLInd(l) + } + + fun addCoverings( + frst: FlightLine, + sec: FlightLine, + ) { + val bmax = getBInd(minOf(frst.leftOuterSwath.lat, frst.rightOuterSwath.lat, sec.leftOuterSwath.lat, sec.rightOuterSwath.lat)) + val bmin = getBInd(maxOf(frst.leftOuterSwath.lat, frst.rightOuterSwath.lat, sec.leftOuterSwath.lat, sec.rightOuterSwath.lat)) + var lmin = getLInd(minOf(frst.leftOuterSwath.long, frst.rightOuterSwath.long, sec.leftOuterSwath.long, sec.rightOuterSwath.long)) + var lmax = getLInd(maxOf(frst.leftOuterSwath.long, frst.rightOuterSwath.long, sec.leftOuterSwath.long, sec.rightOuterSwath.long)) + + if (lmax - lmin > 90) { + lmax = checkLmax(frst, sec) + lmin = checkLmin(frst, sec) + val l = lmin + lmin = lmax + lmax = l + 179 + } + + for (bi in bmin..bmax) { + for (li in lmin..lmax) { + val ll = if (li >= 180) li - 180 else li + val ind = bi * 180 + ll + if (coverings.containsKey(ind)) { + coverings[ind]!!.add(FleghtLineSector(frst.t, sec.t)) + } else { + coverings.put(ind, mutableListOf(FleghtLineSector(frst.t, sec.t))) + } + } + } + } + + + fun checkLmax(frst: FlightLine, sec: FlightLine) : Int{ + var buf = 2 * PI + if (frst.leftOuterSwath.long > PI / 2) { + buf = frst.leftOuterSwath.long + } + if (frst.rightOuterSwath.long > PI / 2 && frst.rightOuterSwath.long < buf) { + buf = frst.rightOuterSwath.long + } + if (sec.rightOuterSwath.long > PI / 2 && sec.rightOuterSwath.long < buf) { + buf = sec.rightOuterSwath.long + } + if (sec.leftOuterSwath.long > PI / 2 && sec.leftOuterSwath.long < buf) { + buf = sec.leftOuterSwath.long + } + return getLInd(buf) + } + + fun checkLmin(frst: FlightLine, sec: FlightLine) : Int{ + var buf = 0.0 + if (frst.leftOuterSwath.long < PI / 2) { + buf = frst.leftOuterSwath.long + } + if (frst.rightOuterSwath.long < PI / 2 && frst.rightOuterSwath.long > buf) { + buf = frst.rightOuterSwath.long + } + if (sec.rightOuterSwath.long < PI / 2 && sec.rightOuterSwath.long > buf) { + buf = sec.rightOuterSwath.long + } + if (sec.leftOuterSwath.long < PI / 2 && sec.leftOuterSwath.long > buf) { + buf = sec.leftOuterSwath.long + } + return getLInd(buf) + } + + fun getCoverings( + b: Double, + l: Double, + ): Iterable { + return coverings.getOrDefault(getInd(b, l), listOf()) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/FlightLineCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/FlightLineCalculator.kt new file mode 100644 index 0000000..db8a88c --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/FlightLineCalculator.kt @@ -0,0 +1,82 @@ +package ballistics.flightLine + +import ballistics.orbitalPoints.AbstractOrbPointsCalculator +import ballistics.types.BallisticsError +import ballistics.types.FlightLine +import ballistics.types.OrbitalPoint +import ballistics.types.Orientation +import ballistics.types.WorkCSType +import ballistics.utils.earth.getEarth + + +internal class FlightLineCalculator( + var opc: AbstractOrbPointsCalculator, + var rollMin: Double, + var rollMax: Double, + var wcs: WorkCSType = WorkCSType.WCSOrbit, +) { + var step = 60.0 + val eart = getEarth(opc.earthType) + var flightLine = mutableListOf() + var pc = PointOnEarthCalculator(opc.earthType, wcs) + var earthCoveringCalculator: EarthCovering = EarthCovering() + var needToCalcEarthCovering: Boolean = true + + + fun calculate( + tn: Double, + tk: Double, + ): BallisticsError { + flightLine.clear() + earthCoveringCalculator.coverings.clear() + + var t = tn + + val stepper = opc.getStepper() + var point: OrbitalPoint? + + point = stepper.calculate(t) + if (point == null) + return BallisticsError.STEPPER_ERROR + + flightLine.add(calcFl(point)) + t += step + + while (t <= tk) { + point = stepper.calculate(t) + if (point == null) { + println("Ошибка выхода на заданное время в середине расчета") + return BallisticsError.STEPPER_ERROR + } + val fl = calcFl(point) + + if (needToCalcEarthCovering) { + earthCoveringCalculator.addCoverings(flightLine.last(), fl) + } + + flightLine.add(fl) + t += step + } + + return BallisticsError.OK + } + + private fun calcFl(point: OrbitalPoint): FlightLine { + return FlightLine( + point.t, + point.vit, + if (point.v.z >= 0) 0 else 1, + pc.pointOnEarth(point, Orientation(0.0, -rollMax, 0.0))!!, + pc.pointOnEarth(point, Orientation(0.0, -rollMin, 0.0))!!, + pc.pointOnEarth(point, Orientation(0.0, 0.0, 0.0))!!, + pc.pointOnEarth(point, Orientation(0.0, rollMin, 0.0))!!, + pc.pointOnEarth(point, Orientation(0.0, rollMax, 0.0))!!, + ) + } + + fun clear(){ + flightLine.clear() + earthCoveringCalculator.coverings.clear() + opc.clear() + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/PointOnEarthCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/PointOnEarthCalculator.kt new file mode 100644 index 0000000..9a78783 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/flightLine/PointOnEarthCalculator.kt @@ -0,0 +1,191 @@ +package ballistics.flightLine + +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.types.Orientation +import ballistics.types.THBLPoint +import ballistics.types.WorkCSType +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.math.Matrix3D +import ballistics.utils.math.Vector3D +import kotlin.math.asin +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +class PointOnEarthCalculator(val earthType: EarthType, private val wcs: WorkCSType) { + private var astro = AstronomerJ2000(earthType) + + fun pointOnEarth( + point: OrbitalPoint, + orientation: Orientation, + ): THBLPoint? { + val krenMax = asin((astro.earth.polarRadius - 50000.0) / point.r.module()) + if (orientation.kren > krenMax) { + orientation.kren = krenMax + } + if (orientation.kren < -krenMax) { + orientation.kren = -krenMax + } + + val rabs: Vector3D + val vabs: Vector3D + + if (wcs == WorkCSType.WCSPath) { + rabs = point.r + vabs = point.v + } else { + val ask = astro.grinvToASK(point) + + rabs = ask.r + vabs = ask.v + } + val dd: Vector3D + val c = Matrix3D() + c.second = rabs.basis() + c.third = (vabs.rem(rabs)).basis() + c.first = c.second.rem(c.third) + var ct = c.transpose() + val aa = + Matrix3D( + Vector3D( + cos(orientation.tang) * cos(orientation.risk) - sin(orientation.tang) * sin(orientation.kren) * sin(orientation.risk), + -sin(orientation.tang) * cos(orientation.kren), + cos(orientation.tang) * sin(orientation.risk) + sin(orientation.tang) * sin(orientation.kren) * cos(orientation.risk), + ), + Vector3D( + sin(orientation.tang) * cos(orientation.risk) + cos(orientation.tang) * sin(orientation.kren) * sin(orientation.risk), + cos(orientation.tang) * cos(orientation.kren), + sin(orientation.tang) * sin(orientation.risk) - cos(orientation.tang) * sin(orientation.kren) * cos(orientation.risk), + ), + Vector3D( + -cos(orientation.kren) * sin(orientation.risk), + sin(orientation.kren), + cos(orientation.kren) * cos(orientation.risk), + ), + ) + if (wcs == WorkCSType.WCSOrbit) { + val g = Matrix3D() + g.makeOzMatrix(astro.si2000(point.t)) + ct = g.transpose() * ct + } + + val k = ct * aa + + val d = Vector3D(0.0, 1.0, 0.0).basis() + + dd = k * d + + return earthIntersection(point.t, point.r, dd) + } + + /** + * расчет точки пересечени отрезка, определенного вектором dd из точка rotn и земной поверхности + */ + private fun earthIntersection( + t: Double, + rotn: Vector3D, + dd: Vector3D, + ): THBLPoint? { + var dz = dd.z + if (dz < 0) dz *= -1 + val x1: Double + val y1: Double + val z1: Double + val x2: Double + val y2: Double + val z2: Double + val r: Vector3D + val h: Double + + val ekvRadiusOverPolarRadiusSquared = (astro.earth.ekvRadius / astro.earth.polarRadius).pow(2) + if (dz < 0.1) { + val k1 = dd.y / dd.x + val k2 = rotn.y - k1 * rotn.x + val k3 = dd.z / dd.x + val k4 = rotn.z - k3 * rotn.x + val k5 = k1 * k1 + ekvRadiusOverPolarRadiusSquared * k3 * k3 + 1 + val k6 = 2 * (k1 * k2 + ekvRadiusOverPolarRadiusSquared * k3 * k4) + val k7 = k2 * k2 + ekvRadiusOverPolarRadiusSquared * k4 * k4 - astro.earth.ekvRadius * astro.earth.ekvRadius + + if ((k6 * k6 - k5 * k7 * 4) < 0) { + return null + } + + + x1 = (-k6 + sqrt(k6 * k6 - 4 * k5 * k7)) / (2 * k5) + y1 = k1 * x1 + k2 + z1 = k3 * x1 + k4 + + + x2 = (-k6 - sqrt(k6 * k6 - 4 * k5 * k7)) / (2 * k5) + y2 = k1 * x2 + k2 + z2 = k3 * x2 + k4 + } else { + val k1 = dd.x / dd.z + val k2 = rotn.x - k1 * rotn.z + val k3 = dd.y / dd.z + val k4 = rotn.y - k3 * rotn.z + val k5 = k1 * k1 + k3 * k3 + ekvRadiusOverPolarRadiusSquared + val k6 = 2 * (k1 * k2 + k3 * k4) + val k7 = k2 * k2 + k4 * k4 - astro.earth.ekvRadius * astro.earth.ekvRadius + + if ((k6 * k6 - 4 * k5 * k7) < 0) { + return null + } + + + z1 = (-k6 + sqrt(k6 * k6 - 4 * k5 * k7)) / (2 * k5) + y1 = k3 * z1 + k4 + x1 = k1 * z1 + k2 + + + z2 = (-k6 - sqrt(k6 * k6 - 4 * k5 * k7)) / (2 * k5) + y2 = k3 * z2 + k4 + x2 = k1 * z2 + k2 + } + val d1 = Vector3D(rotn.x - x1, rotn.y - y1, rotn.z - z1).module() + val d2 = Vector3D(rotn.x - x2, rotn.y - y2, rotn.z - z2).module() + + + if (d1 <= d2) { + r = Vector3D(x1, y1, z1) + h = d1 + } else { + r = Vector3D(x2, y2, z2) + h = d2 + } + + val blh = astro.earth.xyz2blh(r) + + return THBLPoint(blh.lat, blh.long, h, astro.sunAngle(t, r)) + } + + /** + * скорость компенсации (чтобы получить СДИ надо домножить на фокусное расстояние в мм) + */ + fun calculateWD( + point: OrbitalPoint, + orientation: Orientation, + range: Double, + ): Double { + try { + val r = point.r.module() + + val c1: Double = point.r.y * point.v.z - point.r.z * point.v.y + val c2: Double = point.r.z * point.v.x - point.r.x * point.v.z + val c3: Double = point.r.x * point.v.y - point.r.y * point.v.x + val cc = sqrt(c1 * c1 + c2 * c2 + c3 * c3) + + // Трансверсальная скорость V*sin(Q) + val worb = cc / r + + val wd = worb * (r - range * cos(orientation.kren)) / r / range + + return wd + } catch (ex: Exception) { + return 0.0 + } + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/mpl/MPLCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/mpl/MPLCalculator.kt new file mode 100644 index 0000000..dbe7599 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/mpl/MPLCalculator.kt @@ -0,0 +1,143 @@ +package ballistics.mpl + +import ballistics.flightLine.FlightLineCalculator +import ballistics.types.BallisticsError +import ballistics.types.OPKatObj +import ballistics.types.PointViewParams +import ballistics.types.TangageType +import ballistics.utils.math.Vector3D +import ballistics.utils.math.equations.EquationCalculatorSpan +import kotlin.math.PI +import kotlin.math.abs + +internal class MPLCalculator(val flightLineCalculator: FlightLineCalculator, val tangage: Double, tangageType: TangageType) { + private val debugEasyCalc = false + private val stepper = flightLineCalculator.opc.getStepper() + private val orientCalculator = OrientOnPointCalculator(flightLineCalculator.opc.earthType, flightLineCalculator.wcs, tangageType) + private var currentObj = Vector3D() + var sunAngleMin: Double = 0.0 + var useObjConstraints: Boolean = false + + var krenMin = 0.0 + var krenMax = 0.0 + var krenByModule : Boolean = true + + var mpl = mutableListOf() + + fun calculate( + tn: Double, + tk: Double, + objs: Iterable, + ): BallisticsError { + mpl.clear() + val eqc = EquationCalculatorSpan() + eqc.delta = 0.001 * PI / 180.0 + eqc.value = tangage + + val samin = sunAngleMin +// krenMin = flightLineCalculator.rollMin +// krenMax = flightLineCalculator.rollMax + + + for (o in objs) { + if (useObjConstraints) { + krenMin = o.rollMin + krenMax = o.rollMax + sunAngleMin = o.sunAngleMin + } + + currentObj = flightLineCalculator.eart.blh2xyz(o.lat, o.long, o.height) + + val covs = + flightLineCalculator.earthCoveringCalculator.getCoverings( + o.lat, + o.long, + ).filter { (it.tStop >= tn && it.tStart <= tk) } + var tLast = 0.0 + for (cov in covs) { + if (cov.tStart - tLast < 600.0) { + continue + } + + var isOk: Boolean + var traverz: Double? + if (!debugEasyCalc) { + traverz = eqc.calculate(cov.tStart, cov.tStop, this::equation) + isOk = traverz != null + } else { + traverz = (cov.tStart + cov.tStop) / 2 + isOk = true + } + + if (isOk && tryToAddViewParams(traverz!!, o)) { + tLast = traverz + } + } + } + sunAngleMin = samin + mpl.sortBy { it.traverz } + return BallisticsError.OK + } + + fun tryToAddViewParams( + t: Double, + o: OPKatObj, + ): Boolean { + val ka = stepper.calculate(t) + if (ka != null) { + val orient = orientCalculator.calculateOrientOnPoint(ka, currentObj) + val w = orientCalculator.pointInWCS(ka, currentObj) + val blh = flightLineCalculator.eart.xyz2blh(ka.r) + + val vp = + PointViewParams( + o.objON, + o.objN, + o.objUUID, + o.pointNumb, + ka.vit, + t, + blh.lat, + blh.long, + orient, + w.module(), + orientCalculator.astro.sunAngle(t, currentObj), + orientCalculator.calculateVisirAngle(ka, o.lat, o.long, o.height), + if (ka.v.z > 0) 0 else 1 + ) + + if ((vp.sunAngle >= sunAngleMin) && ( ( krenByModule && + (abs(vp.orientation.kren) >= krenMin) && + (abs(vp.orientation.kren) <= krenMax) + ) || + ( !krenByModule && + (vp.orientation.kren >= krenMin) && + (vp.orientation.kren <= krenMax) + ) + ) + ) { + mpl.add(vp) + } + return true + } + + return false + } + + private fun equation(x: Double): Double { + var tang = -1.0 + val ka = stepper.calculate(x) + if (ka != null) { + val orient = orientCalculator.calculateOrientOnPoint(ka, currentObj) + tang = orient.tang + } + return tang + } + + fun clear(){ + flightLineCalculator.clear() + stepper.clear() + mpl.clear() + + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/mpl/OrientOnPointCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/mpl/OrientOnPointCalculator.kt new file mode 100644 index 0000000..12e2cb4 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/mpl/OrientOnPointCalculator.kt @@ -0,0 +1,113 @@ +package ballistics.mpl + +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.types.Orientation +import ballistics.types.TangageType +import ballistics.types.WorkCSType +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.math.Matrix3D +import ballistics.utils.math.Vector3D +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sign +import kotlin.math.sin +import kotlin.math.sqrt + +internal class OrientOnPointCalculator(val earthType: EarthType, val wcs: WorkCSType, val tangType: TangageType) { + var astro = AstronomerJ2000(earthType) + + fun pointInWCS( + ka: OrbitalPoint, + point: Vector3D, + ): Vector3D { + var w: Vector3D + if (wcs == WorkCSType.WCSOrbit) { + + val kaA = astro.grinvToASK(ka) + + val pa = astro.grinvToASK(point, ka.t) + + + + val c = Matrix3D() + c.second = kaA.r.basis() + c.third = (kaA.v.rem(kaA.r)).basis() + c.first = c.second.rem(c.third) + + + val d: Vector3D = pa - kaA.r + + w = c * d + } else { + val c = Matrix3D() + c.second = ka.r.basis() + c.third = (ka.v.rem(ka.r)).basis() + c.first = c.second.rem(c.third) + + + val d: Vector3D = point - ka.r + + w = c * d + } + + return w + } + + fun calculateOrientOnPoint( + ka: OrbitalPoint, + point: Vector3D, + ): Orientation { + val orient = Orientation(0.0, 0.0, 0.0) + val w = pointInWCS(ka, point) + + if (tangType == TangageType.TTProactive) { + orient.tang = PI - atan2(w.x, w.y) + orient.kren = atan2(w.z, w.y / cos(orient.tang)) - PI + } else { + orient.kren = atan2(w.z, w.y) - PI + orient.tang = PI - atan2(w.x, w.z / sin(orient.kren)) + } + + if (abs(orient.tang) > PI) { + orient.tang -= sign(orient.tang) * PI * 2 + } + if (abs(orient.kren) > PI) { + orient.kren -= sign(orient.kren) * PI * 2 + } + + return orient + } + + fun calculateVisirAngle( + ka: OrbitalPoint, + b: Double, + l: Double, + h: Double, + ): Double { + val rp = astro.earth.blh2xyz(b, l, h) + val r1 = Vector3D() + val r = Vector3D() + + r1.x = ka.r.z - rp.z + r1.y = ka.r.x - rp.x + r1.z = ka.r.y - rp.y + + val r4 = r1.y * cos(l) + r1.z * sin(l) + + r.x = cos(b) * r1.x - sin(b) * r4 + r.y = sin(b) * r1.x + cos(b) * r4 + r.z = cos(l) * r1.z - sin(l) * r1.y + + var angV: Double = sqrt(r.x * r.x + r.z * r.z) + if (angV != 0.0) { + angV = atan(r.y / sqrt(r.x * r.x + r.z * r.z)) + angV = PI / 2 - angV + } + + return angV + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/AbstractOrbPointsCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/AbstractOrbPointsCalculator.kt new file mode 100644 index 0000000..af1b401 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/AbstractOrbPointsCalculator.kt @@ -0,0 +1,40 @@ +package ballistics.orbitalPoints + +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.types.RevolutionParameter +import ballistics.utils.earth.getEarth + +abstract class AbstractOrbPointsCalculator(var earthType: EarthType) { + val points = mutableListOf() + val revolutions = mutableListOf() + val earth = getEarth(earthType) + var step = 60.0 + + abstract fun calculate( + tbegin: Double, + tend: Double, + ): BallisticsError + + abstract fun getStepper(): AbstractStepper + + protected abstract fun fastStep(t: Double): OrbitalPoint + + protected fun addRevolution(t: Double) { + val p = fastStep(t) + val blh = earth.xyz2blh(p.r) + revolutions.add(RevolutionParameter(p, blh.long, blh.h)) + } + + protected fun equation(x: Double): Double { + val p = fastStep(x) + return p.r.z + } + + fun clear(){ + points.clear() + revolutions.clear() + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/OrbitalPointsIntegrator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/OrbitalPointsIntegrator.kt new file mode 100644 index 0000000..cb6c96c --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/OrbitalPointsIntegrator.kt @@ -0,0 +1,232 @@ +package ballistics.orbitalPoints + +import ballistics.orbitalPoints.integrator.AbstractIntegrator +import ballistics.orbitalPoints.integrator.IntegratorAdams +import ballistics.orbitalPoints.integrator.IntegratorRK4 +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.orbitalPoints.timeStepper.RungeStepper +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.InitialConditions +import ballistics.types.IntegrationType +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.utils.math.Vector3D +import ballistics.utils.math.equations.AbstractEquationCalculator +import ballistics.utils.math.equations.EquationCalculatorSpan +import kotlin.math.abs + +internal class OrbitalPointsIntegrator(val moddv: ModDVType, integratorType: IntegrationType, earthType: EarthType) : + AbstractOrbPointsCalculator(earthType) { + private val integrator: AbstractIntegrator + private val fastStepper: IntegratorRK4 + var mic = mutableListOf() + private var x = arrayOf() + private var vit = 0 + val equationCalculator: AbstractEquationCalculator = EquationCalculatorSpan() + var mTn : Double = 0.0 + + init { + when (integratorType) { + IntegrationType.RUNG4 -> { + integrator = IntegratorRK4(moddv, earthType) + fastStepper = IntegratorRK4(moddv, earthType) + } + IntegrationType.ADAMS7 -> { + integrator = IntegratorAdams(moddv, earthType) + fastStepper = IntegratorRK4(moddv, earthType) + } + } + equationCalculator.delta = 0.001 + } + + constructor( + nu: InitialConditions, + modDVType: ModDVType, + integratorType: IntegrationType, + earthType: EarthType, + ) : this(modDVType, integratorType, earthType) { + mic.clear() + mic.add(nu) + } + constructor( + nu: Array, + modDVType: ModDVType, + integratorType: IntegrationType, + earthType: EarthType, + ) : this(modDVType, integratorType, earthType) { + mic.clear() + mic.addAll(nu) + } + + + /** + * Расчет точек орбиты + * @param nu начальные условия движения центра масс КА + * @param tn время начала расчета + * @param tk время конца расчета + */ + fun calculate( + nu: InitialConditions, + tn: Double, + tk: Double, + ): BallisticsError { + mTn = tn + var t = nu.point.t + x = arrayOf(nu.point.v.x, nu.point.v.y, nu.point.v.z, nu.point.r.x, nu.point.r.y, nu.point.r.z, nu.point.t) + + integrator.setSBall(nu.sBall) + fastStepper.setSBall(nu.sBall) + integrator.setF81(nu.f81) + fastStepper.setF81(nu.f81) + integrator.accelerate(x) + points.clear() + revolutions.clear() + + val equationCalculator: AbstractEquationCalculator = EquationCalculatorSpan() + equationCalculator.delta = 0.001 + + vit = nu.point.vit + + + if (abs(nu.point.r.z) < 0.00001 && nu.point.t >= tn) { + addRevolution(nu.point.t) + } + + while (t <= tk) { + integrator.nextStep(x) + nextPoint(t) + for (i in 0..6) + x[i] = integrator.y[i] + t += integrator.step + } + + return BallisticsError.OK + } + + + fun nextPoint(t : Double){ + if (x[5] < 0 && integrator.y[5] >= 0) { + ++vit + val tvuz = equationCalculator.calculate(t, t + integrator.step, this::equation) + tvuz?.let { + if (tvuz >= mTn) { + addRevolution(it) + } + } + } + if (t >= mTn) { + val p = OrbitalPoint(t, vit, Vector3D(x[3], x[4], x[5]), Vector3D(x[0], x[1], x[2])) + points.add(p) + } + } + + + /** + * Расчет точек орбиты + * @param mIC массив начальных условий движения центра масс КА + * @param tn время начала расчета + * @param tk время конца расчета + */ + fun calculate( + mIC: Array, + tn: Double, + tk: Double, + ): BallisticsError { + points.clear() + revolutions.clear() + mTn = tn + + + if (mIC.isEmpty()) + return BallisticsError.EMPTY_NU + + + var t = mIC[0].point.t + x = + arrayOf( + mIC[0].point.v.x, + mIC[0].point.v.y, + mIC[0].point.v.z, + mIC[0].point.r.x, + mIC[0].point.r.y, + mIC[0].point.r.z, + mIC[0].point.t, + ) + integrator.setSBall(mIC[0].sBall) + fastStepper.setSBall(mIC[0].sBall) + integrator.setF81(mIC[0].f81) + fastStepper.setF81(mIC[0].f81) + integrator.accelerate(x) + vit = mIC[0].point.vit + + var tkk: Double + for (i in mIC.indices) { + tkk = if (i == mIC.size - 1) tk else mIC[i + 1].point.t + while (t < tkk) { + integrator.nextStep(x) + + nextPoint(t) + for (j in 0..6) + x[j] = integrator.y[j] + t += integrator.step + } + + if (i < mIC.size - 1) { + if (points.isNotEmpty() && points.last().t >= mIC[i + 1].point.t) { + points.removeLast() + } + arrayOf( + mIC[i + 1].point.v.x, + mIC[i + 1].point.v.y, + mIC[i + 1].point.v.z, + mIC[i + 1].point.r.x, + mIC[i + 1].point.r.y, + mIC[i + 1].point.r.z, + mIC[i + 1].point.t, + ).also { x = it } + integrator.setSBall(mIC[i + 1].sBall) + fastStepper.setSBall(mIC[i + 1].sBall) + integrator.setF81(mIC[i + 1].f81) + fastStepper.setF81(mIC[i + 1].f81) + integrator.accelerate(x) + + vit = mIC[i + 1].point.vit + t = mIC[i + 1].point.t + } + } + return BallisticsError.OK + } + + override fun calculate( + tbegin: Double, + tend: Double, + ): BallisticsError { + if (mic.isEmpty()) { + return BallisticsError.EMPTY_NU + } else if (mic.size == 1) { + calculate(mic.first(), tbegin, tend) + } else { + calculate(mic.toTypedArray(), tbegin, tend) + } + return BallisticsError.OK + } + + override fun getStepper(): AbstractStepper { + return RungeStepper(this) + } + + override fun fastStep(t: Double): OrbitalPoint { + val dt = t - x[6] + fastStepper.step = dt + fastStepper.nextStep(x) + val p : OrbitalPoint = OrbitalPoint( + t, + vit, + Vector3D(fastStepper.y[3], fastStepper.y[4], fastStepper.y[5]), + Vector3D(fastStepper.y[0], fastStepper.y[1], fastStepper.y[2]), + ) + return p + } + +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/OrbitalPointsTLE.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/OrbitalPointsTLE.kt new file mode 100644 index 0000000..eff1289 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/OrbitalPointsTLE.kt @@ -0,0 +1,70 @@ +package ballistics.orbitalPoints + +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.orbitalPoints.timeStepper.TLEStepper +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.utils.math.equations.AbstractEquationCalculator +import ballistics.utils.math.equations.EquationCalculatorSpan +import kotlin.math.abs + +internal class OrbitalPointsTLE(str1: String, str2: String, earthType: EarthType) : + AbstractOrbPointsCalculator(earthType) { + val stepper = TLEStepper(str1, str2, earthType) + private var vit = 0 + + override fun calculate( + tbegin: Double, + tend: Double, + ): BallisticsError { + points.clear() + revolutions.clear() + + val equationCalculator: AbstractEquationCalculator = EquationCalculatorSpan() + equationCalculator.delta = 0.001 + + vit = stepper.satellite.orbit.orbitNum + var t = stepper.baseEpoch.toDouble() + var p = stepper.calculate(t) + var pn: OrbitalPoint + if (abs(p.r.z) < 0.00001 && p.t >= tbegin) { + addRevolution(p.t) + } + + try { + while (t <= tend) { + t = t + step + pn = stepper.calculate(t) + if (p.r.z < 0 && pn.r.z >= 0) { + ++vit + val tvuz = equationCalculator.calculate(t - step, t + step, this::equation) + tvuz?.let { + if (tvuz >= tbegin) { + addRevolution(it) + } + } + } + if (t >= tbegin) { + pn.vit = vit + points.add(pn) + } + p = pn + } + }catch (ex : Exception){ + return BallisticsError.STEPPER_ERROR + } + + return BallisticsError.OK + } + + override fun getStepper(): AbstractStepper { + return stepper + } + + override fun fastStep(t: Double): OrbitalPoint { + val op = stepper.calculate(t) + op.vit = vit + return op + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/AbstractIntegrator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/AbstractIntegrator.kt new file mode 100644 index 0000000..78bbd65 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/AbstractIntegrator.kt @@ -0,0 +1,38 @@ +package ballistics.orbitalPoints.integrator + +import ballistics.orbitalPoints.integrator.modDv.AbstractMDV +import ballistics.orbitalPoints.integrator.modDv.MDVBars +import ballistics.orbitalPoints.integrator.modDv.MDVFoto +import ballistics.orbitalPoints.integrator.modDv.MDVKondor +import ballistics.types.AstroType +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.ModDVType + +internal abstract class AbstractIntegrator(var mdType: ModDVType, earthType: EarthType) { + protected val a = arrayOf(1, 2, 2, 1) + protected val prav: AbstractMDV + var step = 60.0 + val y = arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + init { + when (mdType) { + ModDVType.FOTO -> prav = MDVFoto(earthType) + ModDVType.METEORM1 -> prav = MDVKondor(earthType, AstroType.ATJ2000, 8, true) + ModDVType.METEORM2 -> prav = MDVKondor(earthType, AstroType.ATJ2000, 8, true) + ModDVType.KONDOR -> prav = MDVKondor(earthType, AstroType.ATJ2000, 24, false) + ModDVType.KONDOR_PROGNOZ -> prav = MDVKondor(earthType, AstroType.ATJ2000, 24, true) + ModDVType.BARS -> prav = MDVBars(earthType) + } + } + + abstract fun nextStep(x: Array): BallisticsError + + abstract fun accelerate(x: Array): BallisticsError + + abstract fun setSBall(s: Double) + + fun setF81(f81: Double) { + prav.f81 = if (f81 < 25.0) 25.0 else f81 + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/IntegratorAdams.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/IntegratorAdams.kt new file mode 100644 index 0000000..af05aef --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/IntegratorAdams.kt @@ -0,0 +1,157 @@ +package ballistics.orbitalPoints.integrator + +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.ModDVType + +internal class IntegratorAdams(mdType: ModDVType, earthType: EarthType) : AbstractIntegrator(mdType, earthType) { + private val rng: IntegratorRK4 = IntegratorRK4(mdType, earthType) + + val a2 = + arrayOf( + 0.11367394179894179894E-1, + -0.93840939153439153439E-1, + 0.343080357142857142857, + -0.732035383597883597884, + 0.1017964616402116402116E+1, + -0.1006919642857142857143E+1, + 0.1156159060846560846560E+1, + 0.304224537037037037037, + ) + + val a1 = + arrayOf( + -0.304224537037037037037, + 0.2445163690476190476190E+1, + -0.8612127976190476190476E+1, + 0.17379654431216931216931E+2, + -0.22027752976190476190476E+2, + 0.18054538690476190476190E+2, + -0.9525206679894179894180E+1, + 0.3589955357142857142857E+1, + ) + + var intpoints = + arrayOf( + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ) + + override fun accelerate(x: Array): BallisticsError { + for (m in 0..6) + intpoints[0][m] = x[m] + + var er = prav.calculate(intpoints[0]) + if (er != BallisticsError.OK) { + return er + } + for (n in 0..6) + intpoints[9][n] = prav.y[n] + + // кратность шага по Рунге шагу по Адамсу + val dStep = 2 + val ihrng = -step / dStep + rng.step = ihrng + // текущий элемент массива + var j = 8 + + val fld1 = arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + val fld2 = arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + val fld3 = arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + for (m in 0..6) + fld1[m] = x[m] + + for (i in 0..6) { + for (q in 0..1) { + er = rng.nextStep(fld1) + if (er != BallisticsError.OK) { + return er + } + for (m in 0..6) + fld2[m] = rng.y[m] + + for (m in 0..6) + fld1[m] = fld2[m] + } + + er = prav.calculate(fld1) + if (er != BallisticsError.OK) { + return er + } + for (m in 0..6) + fld3[m] = prav.y[m] + + for (m in 0..6) + intpoints[j][m] = fld3[m] + + j-- + } + + return BallisticsError.OK + } + + override fun setSBall(s: Double) { + prav.sBall = s + rng.setSBall(s) + } + + private fun copyPoints(from : Int, to : Int){ + for (m in 0..6) + intpoints[from][m] = intpoints[to][m] + } + + override fun nextStep(x: Array): BallisticsError { + for (m in 0..6) + intpoints[1][m] = x[m] + + // Экстрaполяция + var r: Double + for (j in 1..6) { + r = 0.0 + for (k in 1..8) + r = r + intpoints[1 + k][j - 1] * a1[k - 1] + + intpoints[1][j - 1] = intpoints[1][j - 1] + r * step + } + intpoints[1][6] = intpoints[1][6] + step + + var er = prav.calculate(intpoints[1]) + if (er != BallisticsError.OK) { + return er + } + for (n in 0..6) + intpoints[10][n] = prav.y[n] + + copyPoints(1,0) + + // Интерполяция + for (j in 1..6) { + r = 0.0 + for (k in 1..8) + r = r + intpoints[2 + k][j - 1] * a2[k - 1] + + intpoints[1][j - 1] = intpoints[1][j - 1] + step * r + } + intpoints[1][6] = intpoints[1][6] + step + + er = prav.calculate(intpoints[1]) + if (er != BallisticsError.OK) + return er + for (n in 0..6) + intpoints[10][n] = prav.y[n] + for (m in 0..6) + y[m] = intpoints[1][m] + for (j in 0..9) + copyPoints(j, j+1) + return BallisticsError.OK + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/IntegratorRK4.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/IntegratorRK4.kt new file mode 100644 index 0000000..2e08c5c --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/IntegratorRK4.kt @@ -0,0 +1,42 @@ +package ballistics.orbitalPoints.integrator + +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.ModDVType + +internal class IntegratorRK4(mdType: ModDVType, earthType: EarthType) : AbstractIntegrator(mdType, earthType) { + override fun accelerate(x: Array): BallisticsError { + return BallisticsError.OK + } + + override fun setSBall(s: Double) { + prav.sBall = s + } + + override fun nextStep(x: Array): BallisticsError { + val step6 = step / 6 + var r = arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + for (i in 0..6) { + y[i] = x[i] + r[i] = x[i] + } + for (j in 0..3) { + var er = prav.calculate(r) + if (er != BallisticsError.OK) { + return er + } + + for (i in 0..5) + y[i] = y[i] + step6 * a[j] * prav.y[i] + if (j == 3) { + break + } + for (i in 0..5) + r[i] = x[i] + step / a[j + 1] * prav.y[i] + } + y[6] = x[6] + step + + return BallisticsError.OK + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/AbstractMDV.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/AbstractMDV.kt new file mode 100644 index 0000000..35fa35d --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/AbstractMDV.kt @@ -0,0 +1,36 @@ +package ballistics.orbitalPoints.integrator.modDv + +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.utils.earth.AbstractEarth +import ballistics.utils.earth.getEarth + +internal abstract class AbstractMDV(val earthType: EarthType) { + protected val hminimum = 160000.0 + protected var r = 0.0 + protected var v = 0.0 + protected var fik = 0.0 + protected var bca = 0.0 + protected var oma = 0.0 + protected var earth: AbstractEarth = getEarth(earthType) + var sBall = 0.0 + var f81 = 100.0 + var y = arrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + fun prepare(x: Array) { + var z2 = x[5] * x[5] + var r2 = Math.pow(x[3], 2.0) + Math.pow(x[4], 2.0) + z2 + var v2 = x[2] * x[2] + x[1] * x[1] + x[0] * x[0] + r = Math.sqrt(r2) + v = Math.sqrt(v2) + fik = z2 / r2 + var d = 5 * fik + var c = earth.ekvRadius * earth.ekvRadius / r2 * 1.5 * earth.c20 + var b = earth.fM / r / r2 + var a = b * (1 + c * (d - 1)) + bca = 2 * b * c - a + oma = earth.wEarth * earth.wEarth - a + } + + abstract fun calculate(x: Array): BallisticsError +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVBars.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVBars.kt new file mode 100644 index 0000000..23ca7ec --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVBars.kt @@ -0,0 +1,51 @@ +package ballistics.orbitalPoints.integrator.modDv + +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.utils.atmosphere.getAtm81 +import ballistics.utils.geopotential.Geopotencial1990 +import ballistics.utils.math.Vector3D + +internal open class MDVBars(earthType: EarthType) : AbstractMDV(earthType) { + protected var geopotential = Geopotencial1990() + + override fun calculate(x: Array): BallisticsError { + prepare(x) + y[0] = oma * x[3] + earth.wEarth * x[1] * 2 + y[1] = oma * x[4] - earth.wEarth * x[0] * 2 + y[2] = bca * x[5] + + y[3] = x[0] + y[4] = x[1] + y[5] = x[2] + + // УЧЕТ АТМОСФЕРЫ // + + val h = r - earth.ekvRadius * (1.0 - earth.alphaEllips * fik) + if (h < hminimum) { + return BallisticsError.H_MINIMUM_ERROR + } + + val ro = getAtm81(h) + + val srv = v * ro * sBall + + y[0] -= srv * x[0] + y[1] -= srv * x[1] + y[2] -= srv * x[2] + + // УЧЕТ АНОМАЛИЙ ГЕОПОТЕНЦИАЛА // + + var xyz = Vector3D(0.0, 0.0, 0.0) + var rez: BallisticsError = geopotential.anomkond(x, 16, 16, xyz) + if (rez != BallisticsError.OK) { + return rez + } + + y[0] = y[0] + xyz.x / 1000000.0 + y[1] = y[1] + xyz.y / 1000000.0 + y[2] = y[2] + xyz.z / 1000000.0 + + return BallisticsError.OK + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVFoto.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVFoto.kt new file mode 100644 index 0000000..67611c5 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVFoto.kt @@ -0,0 +1,33 @@ +package ballistics.orbitalPoints.integrator.modDv + +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.utils.atmosphere.getAtm62 + +internal class MDVFoto(earthType: EarthType) : AbstractMDV(earthType) { + override fun calculate(x: Array): BallisticsError { + prepare(x) + y[0] = oma * x[3] + earth.wEarth * x[1] * 2 + y[1] = oma * x[4] - earth.wEarth * x[0] * 2 + y[2] = bca * x[5] + + y[3] = x[0] + y[4] = x[1] + y[5] = x[2] + + val h = r - earth.ekvRadius * (1 - earth.alphaEllips * fik) + if (h < hminimum) { + return BallisticsError.H_MINIMUM_ERROR + } + + val ro = getAtm62(h, 6) + + val srv = v * ro * sBall + + y[0] -= srv * x[0] + y[1] -= srv * x[1] + y[2] -= srv * x[2] + + return BallisticsError.OK + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVKondor.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVKondor.kt new file mode 100644 index 0000000..7a7069e --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/integrator/modDv/MDVKondor.kt @@ -0,0 +1,99 @@ +package ballistics.orbitalPoints.integrator.modDv + +import ballistics.types.AstroType +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.atmosphere.Atmosphere2004 +import ballistics.utils.math.Vector3D +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.cos +import kotlin.math.sin + +internal open class MDVKondor( + earthType: EarthType, + var astroType: AstroType, + var garmonics: Int, + var isProgn: Boolean, +) : MDVBars(earthType) { + protected val amax = 0.6378136E+7 + protected val alpha = 3.3528037E-3F + + protected var astro = AstronomerJ2000(earthType) + protected var atm2004 = Atmosphere2004() + + override fun calculate(x: Array): BallisticsError { + prepare(x) + y[0] = oma * x[3] + earth.wEarth * x[1] * 2 + y[1] = oma * x[4] - earth.wEarth * x[0] * 2 + y[2] = bca * x[5] + + y[3] = x[0] + y[4] = x[1] + y[5] = x[2] + + if (!isProgn) { + // УЧЕТ АТМОСФЕРЫ // + + var rb1: Double = fik * alpha + rb1 = 1.0 - rb1 + rb1 *= amax + val h: Double = r - rb1 + if (h < hminimum) { + return BallisticsError.H_MINIMUM_ERROR + } + + var sun = astro.sunCoordinates(x[6]) + sun = astro.askToGrinvich(sun, x[6]).basis() + + var day = (LocalDateTime.ofEpochSecond(x[6].toLong(), 0, ZoneOffset.UTC).dayOfYear - 1).toDouble() + + var ro = atm2004.atm2004Kav(f81, day, h, x, sun.x, sun.y, sun.z) + + var srv = v * ro * sBall + + y[0] -= srv * x[0] + y[1] -= srv * x[1] + y[2] -= srv * x[2] + } + // УЧЕТ АНОМАЛИЙ ГЕОПОТЕНЦИАЛА // + + var xyz = Vector3D(0.0, 0.0, 0.0) + var rez: BallisticsError = geopotential.anomkond(x, garmonics, garmonics, xyz) + if (rez != BallisticsError.OK) { + return rez + } + + y[0] = y[0] + xyz.x / 1000000.0 + y[1] = y[1] + xyz.y / 1000000.0 + y[2] = y[2] + xyz.z / 1000000.0 + + if (!isProgn) { + // УЧЕТ Луны и Солнца + var tsol = astro.si2000(x[6]) + + val cg: Double = cos(tsol) + val sg: Double = sin(tsol) + + // Перевод из ОГЭСК в АГЭСК + + // Перевод из ОГЭСК в АГЭСК + val x2: Double = (x[3] * cg - x[4] * sg) / 1000.0 + val y2: Double = (x[3] * sg + x[4] * cg) / 1000.0 + val z2: Double = x[5] / 1000.0 + + var cor = astro.sunMoonCorrection(x[6], x2, y2, z2) + var ddx = (cor.x * cg + cor.y * sg) * 1000.0 + var ddy = (cor.y * cg - cor.x * sg) * 1000.0 + var ddz = cor.z * 1000.0 + + y[0] = y[0] + ddx // Учет влияния Солнца и Луны + y[1] = y[1] + ddy + y[2] = y[2] + ddz + } + + return BallisticsError.OK + } +} + diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/AbstractStepper.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/AbstractStepper.kt new file mode 100644 index 0000000..8dd5b8b --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/AbstractStepper.kt @@ -0,0 +1,14 @@ +package ballistics.orbitalPoints.timeStepper + +import ballistics.types.OrbitalPoint + +interface AbstractStepper { + fun calculate(t: Double): OrbitalPoint? + + fun calculate( + t: Double, + p: OrbitalPoint, + ): OrbitalPoint? + + fun clear() +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/RungeStepper.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/RungeStepper.kt new file mode 100644 index 0000000..9fc3fa8 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/RungeStepper.kt @@ -0,0 +1,119 @@ +package ballistics.orbitalPoints.timeStepper + +import ballistics.orbitalPoints.AbstractOrbPointsCalculator +import ballistics.orbitalPoints.integrator.AbstractIntegrator +import ballistics.orbitalPoints.integrator.IntegratorRK4 +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.utils.math.Vector3D +import kotlin.math.abs +import kotlin.math.round + +class RungeStepper(var mdType: ModDVType, earthType: EarthType) : AbstractStepper { + + private val integrator: AbstractIntegrator = IntegratorRK4(mdType, earthType) // ModDVType.FOTO//BARS + var points = mutableListOf() + + constructor(source: AbstractOrbPointsCalculator) : this(ModDVType.FOTO, source.earthType) { + points = source.points + } + + constructor(points: MutableList, earth: EarthType) : this(ModDVType.FOTO, earth) { + this.points = points + } + + private fun findPoint(t: Double): OrbitalPoint? { + val cnt = points.size + val dt = t - points.first().t + var ind: Int = round(dt / integrator.step).toInt() + if (ind >= -1 && ind < cnt + 1) { + if (ind < 0) { + ind = 0 + } + if (ind >= cnt) { + ind = cnt - 1 + } + while ((t - points[ind].t > integrator.step) && (ind < points.size)) { + ++ind + } + if (t - points[ind].t < 0 && ind > 0) { + --ind + } + return points[ind] + } + return null + } + + override fun calculate(t: Double): OrbitalPoint? { + val p = findPoint(t) + return p?.let { + val dt = t - p.t + val st = integrator.step + integrator.step = dt + + val x = arrayOf(p.v.x, p.v.y, p.v.z, p.r.x, p.r.y, p.r.z, p.t) + val r = integrator.nextStep(x) + if (r != BallisticsError.OK) { + return null + } + + var vit = p.vit + if (dt >= 0 && p.r.z > 0 && x[5] < 0 && p.v.z > 0) { + vit++ + } + if (dt < 0 && p.r.z < 0 && x[5] > 0 && p.v.z > 0) { + vit-- + } + + integrator.step = st + OrbitalPoint( + integrator.y[6], + vit, + Vector3D(integrator.y[3], integrator.y[4], integrator.y[5]), + Vector3D(integrator.y[0], integrator.y[1], integrator.y[2]), + ) + } ?: run { + null + } + } + + override fun clear() { + points.clear() + } + + override fun calculate( + t: Double, + p: OrbitalPoint, + ): OrbitalPoint? { + val dt = t - p.t + if (abs(dt) > 80.0) { + return null + } + + val st = integrator.step + integrator.step = dt + + val x = arrayOf(p.v.x, p.v.y, p.v.z, p.r.x, p.r.y, p.r.z, p.t) + if (integrator.nextStep(x) != BallisticsError.OK) { + return null + } + + var vit = p.vit + if (dt >= 0 && p.r.z > 0 && x[5] < 0 && p.v.z > 0) { + vit++ + } + if (dt < 0 && p.r.z < 0 && x[5] > 0 && p.v.z > 0) { + vit-- + } + + integrator.step = st + return OrbitalPoint( + integrator.y[6], + vit, + Vector3D(integrator.y[3], integrator.y[4], integrator.y[5]), + Vector3D(integrator.y[0], integrator.y[1], integrator.y[2]), + ) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt new file mode 100644 index 0000000..3c5fdaa --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/orbitalPoints/timeStepper/TLEStepper.kt @@ -0,0 +1,96 @@ +package ballistics.orbitalPoints.timeStepper + +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.math.Vector3D +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.TLE +import org.nstart.dep265.tletools.zeptomoby.orbit.Satellite +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.TimeZone + +class TLEStepper(str1: String, str2: String, earthType: EarthType) : AbstractStepper { + val astro = AstronomerJ2000(earthType) + val tleParser: TLE = TLE("", str1, str2) + val satellite: Satellite = Satellite(tleParser) + val baseEpoch: Double + + init { + baseEpoch = extractUTCMillis(satellite) / 1000.0 + 10800 + } + + override fun clear() { + + } + + private fun extractUTCMillis( + year: Int, + month: Int, + dayOfMonth: Double, + ): Long { + val gc = GregorianCalendar() + gc[Calendar.YEAR] = year + gc[Calendar.MONTH] = month - 1 + gc[Calendar.DAY_OF_MONTH] = dayOfMonth.toInt() + + gc.timeZone = TimeZone.getTimeZone("UTC") + + var dfrac = dayOfMonth - dayOfMonth.toLong() + dfrac *= 24.0 + + gc[Calendar.HOUR_OF_DAY] = dfrac.toInt() + dfrac = 60.0 * (dfrac - dfrac.toInt()) + + gc[Calendar.MINUTE] = dfrac.toInt() + dfrac = 60.0 * (dfrac - dfrac.toInt()) + + gc[Calendar.SECOND] = dfrac.toInt() + dfrac = 1000.0 * (dfrac - dfrac.toInt()) + + gc[Calendar.MILLISECOND] = dfrac.toInt() + + return gc.timeInMillis + } + + private fun extractUTCMillis(date: Julian.DateComponent): Long = extractUTCMillis(date.year, date.mon ?: 1, date.dom ?: 1.0) + + private fun extractUTCMillis(sat: Satellite): Long = + extractUTCMillis(sat.orbit.epoch.getComponent()) + + override fun calculate(t: Double): OrbitalPoint { + + try { + val utcMillis = baseEpoch + val dt = t - utcMillis + val pos = satellite.positionEci(dt / 60) + val ask = + OrbitalPoint( + t, + satellite.orbit.orbitNum + (dt / satellite.orbit.period).toInt(), + Vector3D( + pos.position.x * 1000.0, + pos.position.y * 1000.0, + pos.position.z * 1000.0, + ), + Vector3D( + pos.velocity.x * 1000.0, + pos.velocity.y * 1000.0, + pos.velocity.z * 1000.0, + ), + ) + + return astro.askToGrinvich(ask) + } catch (ex : Exception){ + throw ex + } + } + + override fun calculate( + t: Double, + p: OrbitalPoint, + ): OrbitalPoint? { + return calculate(t) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/KotlinSpecificTools.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/KotlinSpecificTools.kt new file mode 100644 index 0000000..ba7adf6 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/KotlinSpecificTools.kt @@ -0,0 +1,57 @@ +package org.nstart.dep265.tletools.tools + +import java.lang.NumberFormatException +import kotlin.math.min + +fun MutableList.mapInPlace(transform: (Double) -> Double) { + for (i in this.indices) + this[i] = transform(this[i]) +} + +fun MutableList.mapInPlaceIndexed(transform: (Int, Double) -> Double) { + for (i in this.indices) + this[i] = transform(i, this[i]) +} + +fun String.leftJustified(size: Int, char: Char = ' ', truncate: Boolean = false): String { + val padLen = size - length + val builder = StringBuilder(this.padEnd(size, char)) + + if (padLen <= 0 && truncate) { + builder.replace(length + padLen, length, "") + } + + return builder.toString() +} + +fun String.rightJustified(size: Int, char: Char = ' ', truncate: Boolean = false): String { + val padLen = size - length + val builder = StringBuilder(this.padStart(size, char)) + + if (padLen <= 0 && truncate) { + builder.replace(length + padLen, length, "") + } + + return builder.toString() +} + +fun String.substringByLength(startIndex: Int, size: Int): String = this.substring(startIndex, min(startIndex + size, this.length)) + +operator fun > Array.get(index: Enum): T = this[index.ordinal] +operator fun > Array.set(index: Enum, value: T) { + this[index.ordinal] = value +} + +fun String?.isParsableToNum(): Boolean { + + if (this == null) return false + + try { + this.toDouble() + } + catch (e: NumberFormatException) { + return false + } + + return true +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Globals.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Globals.kt new file mode 100644 index 0000000..936a973 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Globals.kt @@ -0,0 +1,74 @@ +package org.nstart.dep265.tletools.zeptomoby.core + +import org.nstart.dep265.tletools.zeptomoby.core.iau.IAU +import org.nstart.dep265.tletools.zeptomoby.core.iau.IAU76 +import org.nstart.dep265.tletools.zeptomoby.core.wgs.WGS +import org.nstart.dep265.tletools.zeptomoby.core.wgs.WGS72 +import kotlin.math.atan +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.sqrt + +@Suppress("unused") +object Globals { + + const val pi: Double = Math.PI + const val twoPi: Double = 2.0 * org.nstart.dep265.tletools.zeptomoby.core.Globals.pi + const val radsPerDeg = org.nstart.dep265.tletools.zeptomoby.core.Globals.pi / 180.0 + + const val gm = 398601.2 // Earth gravitational constant, km^3/sec^2 + const val geoSyncAlt = 42241.892 // km + const val earthDia = 12800.0 // km + const val daySidereal = 86164.09 // sec + const val day24HR = 86400 // sec + + const val ae = 1.0 + var iau: IAU = IAU76 + var wgs: WGS = WGS72 + set(value) { + field = value + + org.nstart.dep265.tletools.zeptomoby.core.Globals.ck2 = org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.j2 * 0.5 + org.nstart.dep265.tletools.zeptomoby.core.Globals.ck4 = -3.0 * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.j4 / 8.0 + org.nstart.dep265.tletools.zeptomoby.core.Globals.xj3 = org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.j3 + org.nstart.dep265.tletools.zeptomoby.core.Globals.qo = org.nstart.dep265.tletools.zeptomoby.core.Globals.ae + 120.0 / org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer + org.nstart.dep265.tletools.zeptomoby.core.Globals.s = org.nstart.dep265.tletools.zeptomoby.core.Globals.ae + 78.0 / org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer + org.nstart.dep265.tletools.zeptomoby.core.Globals.xke = sqrt(3600.0 * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.ge / (org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer)) + org.nstart.dep265.tletools.zeptomoby.core.Globals.qoms2t = (org.nstart.dep265.tletools.zeptomoby.core.Globals.qo - org.nstart.dep265.tletools.zeptomoby.core.Globals.s).pow(4) + } + + var ck2 = org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.j2 * 0.5 + var ck4 = -3.0 * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.j4 * 0.125 + var xj3 = org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.j3 + var qo = org.nstart.dep265.tletools.zeptomoby.core.Globals.ae + 120.0 / org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer + var s = org.nstart.dep265.tletools.zeptomoby.core.Globals.ae + 78.0 / org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer + const val hrPerDay = 24.0 // Hours per day (solar) + const val minPerDay = 1440.0 // Minutes per day (solar) + const val secPerDay = 86400.0 // Seconds per day (solar) + const val omegaE = 1.00273790934 // earth rotation per sidereal day + var xke = sqrt(3600.0 * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.ge / //sqrt(ge) ER^3/min^2 + (org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer * org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer)) + var qoms2t = (org.nstart.dep265.tletools.zeptomoby.core.Globals.qo - org.nstart.dep265.tletools.zeptomoby.core.Globals.s).pow(4) //(QO - S)^4 ER^4 + + fun sqr(x: Double) = x*x + + fun fmod(numerator: Double, denominator: Double): Double { + val tquot = floor(numerator / denominator).toLong() + return numerator - tquot * denominator + } + + fun fmod2p(arg: Double): Double { + val modU = org.nstart.dep265.tletools.zeptomoby.core.Globals.fmod( + arg, + org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi + ) + return if (modU < 0.0) modU + org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi else modU + } + + fun acTan(sinX: Double, cosX: Double): Double = + if (cosX == 0.0) (if (sinX > 0.0) org.nstart.dep265.tletools.zeptomoby.core.Globals.pi * 0.5 else 1.5 * org.nstart.dep265.tletools.zeptomoby.core.Globals.pi) + else (if (cosX > 0.0) atan(sinX/cosX) else org.nstart.dep265.tletools.zeptomoby.core.Globals.pi + atan(sinX/cosX)) + + fun rad2deg(r: Double): Double = Math.toDegrees(r) + fun deg2rad(d: Double): Double = Math.toRadians(d) +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Julian.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Julian.kt new file mode 100644 index 0000000..c21c698 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Julian.kt @@ -0,0 +1,120 @@ +package org.nstart.dep265.tletools.zeptomoby.core + +@Suppress("unused") +open class Julian { + + object EpochConst { + const val EPOCH_JAN0_12H_1900 = 2415020.0 // Dec 31.5 1899 = Dec 31 1899 12h UTC + const val EPOCH_JAN1_00H_1900 = 2415020.5 // Jan 1.0 1900 = Jan 1 1900 00h UTC + const val EPOCH_JAN1_12H_2000 = 2451545.0 // Jan 1.5 2000 = Jan 1 2000 12h UTC + } + + object Static { + fun isLeapYear(y: Int): Boolean = + (y % 4 == 0) && (y % 100 != 0) || (y % 400 == 0) + } + + data class DateComponent(val year: Int, val mon: Int? = null, val dom: Double? = null) + + protected var date: Double = 0.0 + + constructor() { + initialize(2000, 1.0) + } + + constructor(year: Int, day: Double) { + initialize(year, day) + } + + constructor(year: Int, mon: Int, day: Int, hour: Int, min: Int, sec: Double = 0.0) { + val f1 = (275.0 * mon / 9.0).toInt() + val f2 = ((mon + 9.0) / 12.0).toInt() + + val n = if (org.nstart.dep265.tletools.zeptomoby.core.Julian.Static.isLeapYear(year)) f1 - f2 + day - 30 + else f1 - 2 * f2 + day - 30 + + val dblDay = n + (hour + (min + sec / 60.0) / 60.0) / 24.0 + initialize(year, dblDay) + } + + fun toGMST(): Double { + val ut = org.nstart.dep265.tletools.zeptomoby.core.Globals.fmod(date + 0.5, 1.0) + val tu = (fromJan1_12h_2000() - ut) / 36525.0 + var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2e-06)) + + gmst = org.nstart.dep265.tletools.zeptomoby.core.Globals.fmod( + gmst + org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay * org.nstart.dep265.tletools.zeptomoby.core.Globals.omegaE * ut, + org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay + ) + + if (gmst < 0.0) gmst += org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay + return org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi * (gmst / org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay) + } + + fun toLMST(lon: Double): Double = org.nstart.dep265.tletools.zeptomoby.core.Globals.fmod( + toGMST() + lon, + org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi + ) + + @Suppress("FunctionName") + fun fromJan0_12h_1900(): Double = date - org.nstart.dep265.tletools.zeptomoby.core.Julian.EpochConst.EPOCH_JAN0_12H_1900 + @Suppress("FunctionName") + fun fromJan1_00h_1900(): Double = date - org.nstart.dep265.tletools.zeptomoby.core.Julian.EpochConst.EPOCH_JAN1_00H_1900 + @Suppress("FunctionName") + fun fromJan1_12h_2000(): Double = date - org.nstart.dep265.tletools.zeptomoby.core.Julian.EpochConst.EPOCH_JAN1_12H_2000 + + fun getComponent(): org.nstart.dep265.tletools.zeptomoby.core.Julian.DateComponent { + val jdAdj = date + 0.5 + val z = jdAdj.toInt() + val f = jdAdj - z + val alpha = ((z - 1867216.25) / 36524.25).toInt() + val a = z + 1 + alpha - (alpha * 0.25).toInt() + val b = a + 1524.0 + val c = ((b - 122.1) / 365.25).toInt() + val d = (c * 365.25).toInt() + val e = ((b - d) / 30.6001).toInt() + + val dom = b - d - (e * 30.6001).toInt() + f + val month = if (e < 13.5) e - 1 else e - 13 + val year = if (month > 2.5) c - 4716 else c - 4715 + + return org.nstart.dep265.tletools.zeptomoby.core.Julian.DateComponent(year, month, dom) + } + + fun addDay(day: Double) { + date += day + } + + fun addHour(hr: Double) { + date += hr / org.nstart.dep265.tletools.zeptomoby.core.Globals.hrPerDay + } + + fun addMin(min: Double) { + date += min / org.nstart.dep265.tletools.zeptomoby.core.Globals.minPerDay + } + + fun addSec(sec: Double) { + date += sec / org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay + } + + fun spanDay (b: org.nstart.dep265.tletools.zeptomoby.core.Julian): Double = date - b.date + fun spanHour(b: org.nstart.dep265.tletools.zeptomoby.core.Julian): Double = spanDay(b) * org.nstart.dep265.tletools.zeptomoby.core.Globals.hrPerDay + fun spanMin (b: org.nstart.dep265.tletools.zeptomoby.core.Julian): Double = spanDay(b) * org.nstart.dep265.tletools.zeptomoby.core.Globals.minPerDay + fun spanSec (b: org.nstart.dep265.tletools.zeptomoby.core.Julian): Double = spanDay(b) * org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay + + protected fun initialize(year: Int, day: Double) { + assert((year > 1582) && (year < 3000)) + assert((day >= 1.0) && (day < 367.0)) + + val iyear = year - 1 + + val a: Int = iyear / 100 + val b: Int = 2 - a + a / 4 + + val newYears: Double = (365.25 * iyear).toInt() + + (30.6001 * 14).toInt() + + 1720994.5 + b + + date = newYears + day + } +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Site.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Site.kt new file mode 100644 index 0000000..ec83d88 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Site.kt @@ -0,0 +1,101 @@ +package org.nstart.dep265.tletools.zeptomoby.core + +import org.nstart.dep265.tletools.zeptomoby.core.Globals.deg2rad +import org.nstart.dep265.tletools.zeptomoby.core.Globals.rad2deg +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.coord.Geo +import org.nstart.dep265.tletools.zeptomoby.core.coord.Topo +import org.nstart.dep265.tletools.zeptomoby.core.eci.EciTime +import kotlin.math.* + +@Suppress("unused") +open class Site { + + constructor(degLat: Double, degLon: Double, kmAlt: Double, n: String) { + name = n + geo = Geo(deg2rad(degLat), deg2rad(degLon), kmAlt) + } + + constructor(degLat: Double, degLon: Double, kmAlt: Double): + this(degLat, degLon, kmAlt, "") + + constructor(g: Geo) { + name = "" + geo = g + } + + override fun toString() = if (name.isEmpty()) "$geo" else "$name $geo" + + fun positionEci(julian: org.nstart.dep265.tletools.zeptomoby.core.Julian): EciTime = EciTime(geo, julian) + + @Deprecated("", ReplaceWith("positionEci(geo, julian)")) + fun position(julian: org.nstart.dep265.tletools.zeptomoby.core.Julian): EciTime = EciTime(geo, julian) + + fun lookAngle(time: EciTime): Topo { + val date: org.nstart.dep265.tletools.zeptomoby.core.Julian = time.date + val eciSite = EciTime(geo, date) + + val vecRgRate = org.nstart.dep265.tletools.zeptomoby.core.Vector( + time.velocity.x - eciSite.velocity.x, + time.velocity.y - eciSite.velocity.y, + time.velocity.z - eciSite.velocity.z + ) + + val x: Double = time.position.x - eciSite.position.x + val y: Double = time.position.y - eciSite.position.y + val z: Double = time.position.z - eciSite.position.z + val w: Double = sqrt(sqr(x) + sqr(y) + sqr(z)) + val vecRange = org.nstart.dep265.tletools.zeptomoby.core.Vector(x, y, z, w) + + val theta = date.toLMST(longitudeRad) + val sinLat = sin(latitudeRad) + val cosLat = cos(latitudeRad) + val sinTheta = sin(theta) + val cosTheta = cos(theta) + + val topS = sinLat * (cosTheta * vecRange.x + sinTheta * vecRange.y) - cosLat * vecRange.z + val topE = -sinTheta * vecRange.x + cosTheta * vecRange.y + val topZ = cosLat * (cosTheta * vecRange.x + sinTheta * vecRange.y) + sinLat * vecRange.z + var az = atan(-topE / topS) + + if (topS > 0.0) az += org.nstart.dep265.tletools.zeptomoby.core.Globals.pi + if (az < 0.0) az += org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi + + var el = asin(topZ / vecRange.w) + val rate = (vecRange.x * vecRgRate.x + + vecRange.y * vecRgRate.y + + vecRange.z * vecRgRate.z) / vecRange.w + + if (atmosphericCorrection) { + val saveEl = el + + el += deg2rad( + (1.02 / tan( + deg2rad(rad2deg(el) + 10.3 / (rad2deg(el) + 5.11)) + )) / 60.0 + ) + + if (el < 0.0) el = saveEl + if (el > org.nstart.dep265.tletools.zeptomoby.core.Globals.pi * 0.5) el = org.nstart.dep265.tletools.zeptomoby.core.Globals.pi * 0.5 + } + + return Topo(az, // azimuth, radians + el, // elevation, radians + vecRange.w, // range, km + rate // rate, km / sec + ) + } + + val latitudeRad: Double + get() = geo.latitudeRad + val longitudeRad: Double + get() = geo.longitudeRad + val latitudeDeg: Double + get() = geo.latitudeDeg + val longitudeDeg: Double + get() = geo.longitudeDeg + + val name: String + val geo: Geo + val atmosphericCorrection = false +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/TLE.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/TLE.kt new file mode 100644 index 0000000..e1974d8 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/TLE.kt @@ -0,0 +1,276 @@ +package org.nstart.dep265.tletools.zeptomoby.core + +import org.nstart.dep265.tletools.tools.get +import org.nstart.dep265.tletools.tools.isParsableToNum +import org.nstart.dep265.tletools.tools.set +import org.nstart.dep265.tletools.tools.substringByLength +import org.nstart.dep265.tletools.zeptomoby.core.TLE.ProtectedStatic.convertUnits + +@Suppress("unused") +open class TLE { + + object TLEPartLength { + const val TLE_LEN_LINE_DATA = 69; const val TLE_LEN_LINE_NAME = 24 + + const val TLE1_COL_SATNUM = 2; const val TLE1_LEN_SATNUM = 5 + const val TLE1_COL_INTLDESC_A = 9; const val TLE1_LEN_INTLDESC_A = 2 + const val TLE1_COL_INTLDESC_B = 11; const val TLE1_LEN_INTLDESC_B = 3 + const val TLE1_COL_INTLDESC_C = 14; const val TLE1_LEN_INTLDESC_C = 3 + const val TLE1_COL_EPOCH_A = 18; const val TLE1_LEN_EPOCH_A = 2 + const val TLE1_COL_EPOCH_B = 20; const val TLE1_LEN_EPOCH_B = 12 + const val TLE1_COL_MEANMOTIONDT = 33; const val TLE1_LEN_MEANMOTIONDT = 10 + const val TLE1_COL_MEANMOTIONDT2 = 44; const val TLE1_LEN_MEANMOTIONDT2 = 8 + const val TLE1_COL_BSTAR = 53; const val TLE1_LEN_BSTAR = 8 + const val TLE1_COL_EPHEMTYPE = 62; const val TLE1_LEN_EPHEMTYPE = 1 + const val TLE1_COL_ELNUM = 64; const val TLE1_LEN_ELNUM = 4 + + const val TLE2_COL_SATNUM = 2; const val TLE2_LEN_SATNUM = 5 + const val TLE2_COL_INCLINATION = 8; const val TLE2_LEN_INCLINATION = 8 + const val TLE2_COL_RAASCENDNODE = 17; const val TLE2_LEN_RAASCENDNODE = 8 + const val TLE2_COL_ECCENTRICITY = 26; const val TLE2_LEN_ECCENTRICITY = 7 + const val TLE2_COL_ARGPERIGEE = 34; const val TLE2_LEN_ARGPERIGEE = 8 + const val TLE2_COL_MEANANOMALY = 43; const val TLE2_LEN_MEANANOMALY = 8 + const val TLE2_COL_MEANMOTION = 52; const val TLE2_LEN_MEANMOTION = 11 + const val TLE2_COL_REVATEPOCH = 63; const val TLE2_LEN_REVATEPOCH = 5 + } + + object Static { + fun isValidLine(str: String, num: org.nstart.dep265.tletools.zeptomoby.core.TLE.Lines): Boolean { + val testStr = str.trim() + + return if (num == org.nstart.dep265.tletools.zeptomoby.core.TLE.Lines.LINE_ZERO) { + testStr.length <= org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE_LEN_LINE_NAME + } else { + testStr.length == org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE_LEN_LINE_DATA && + str[0].digitToInt() == num.ordinal && + str[1] == ' ' + } + } + } + + protected object ProtectedStatic { + fun expToAtof(exp: String): String { + val colSign = 0 + val lenSign = 1 + + val colMantissa = 1 + val lenMantissa = 5 + + val colExponent = 6 + val lenExponent = 2 + + val strBuilder = StringBuilder() + strBuilder.append(exp.substringByLength(colSign, lenSign)) + strBuilder.append("0.") + strBuilder.append(exp.substringByLength(colMantissa, lenMantissa)) + strBuilder.append("e") + strBuilder.append(exp.substringByLength(colExponent, lenExponent).trimStart()) + + return strBuilder.toString() + } + + fun convertUnits(value: Double, fld: org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields, units: org.nstart.dep265.tletools.zeptomoby.core.TLE.Units): Double = + when (fld) { + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_I, + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_RAAN, + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_ARGPER, + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_M -> if (units == org.nstart.dep265.tletools.zeptomoby.core.TLE.Units.U_RAD) org.nstart.dep265.tletools.zeptomoby.core.Globals.deg2rad( + value + ) else value + else -> value + } + + fun checkSum(cs: String): Int = + cs.dropLast(1).fold(0) { + acc: Int, ch: Char -> + when (true) { + ch.isDigit() -> acc + ch.digitToInt() + (ch == '-') -> acc + 1 + else -> acc + } + } + } + + private object PrivateStatic { + const val strDegrees: String = " degrees" + const val strRevsPerDay: String = " revs / day" + + fun getUnits(fld: org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields): String = + when (fld) { + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_I, + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_RAAN, + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_ARGPER, + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_M -> org.nstart.dep265.tletools.zeptomoby.core.TLE.PrivateStatic.strDegrees + org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_MMOTION -> org.nstart.dep265.tletools.zeptomoby.core.TLE.PrivateStatic.strRevsPerDay + else -> "" + } + } + + enum class Lines { + LINE_ZERO, + LINE_ONE, + LINE_TWO + } + + enum class Fields { + FLD_NORADNUM, + FLD_INTLDESC, + FLD_SET, // TLE set number + FLD_EPOCHYEAR, // Epoch: Last two digits of year + FLD_EPOCHDAY, // Epoch: Fractional Julian Day of year + FLD_ORBITNUM, // Orbit at epoch + FLD_I, // Inclination + FLD_RAAN, // R.A. ascending node + FLD_E, // Eccentricity + FLD_ARGPER, // Argument of perigee + FLD_M, // Mean anomaly + FLD_MMOTION, // Mean motion + FLD_MMOTIONDT, // First time derivative of mean motion + FLD_MMOTIONDT2,// Second time derivative of mean motion + FLD_BSTAR, // BSTAR Drag + FLD_LAST // MUST be last + } + + enum class Units { + U_RAD, // radians + U_DEG, // degrees + U_NATIVE, // TLE format native units (no conversion) + U_LAST // MUST be last + } + + val header: String + val first: String + val second: String + + private var field = arrayOfNulls(org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_LAST.ordinal) + private var mapCache = mutableMapOf() + + fun getField(fld: org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields, units: org.nstart.dep265.tletools.zeptomoby.core.TLE.Units = org.nstart.dep265.tletools.zeptomoby.core.TLE.Units.U_NATIVE, bStrUnits: Boolean = false): Pair { + assert((0 <= fld.ordinal) && (fld < org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_LAST)) + assert((0 <= units.ordinal) && (units < org.nstart.dep265.tletools.zeptomoby.core.TLE.Units.U_LAST)) + + val strBuilder = StringBuilder(field[fld]) + if (bStrUnits) strBuilder.append(org.nstart.dep265.tletools.zeptomoby.core.TLE.PrivateStatic.getUnits(fld)) + strBuilder.trim() + + val key = key(units, fld) + val valueInDouble: Double + + if (!mapCache.contains(key)) { + val valStr = field[fld.ordinal] + + if (!valStr.isParsableToNum()) valueInDouble = 0.0 + else { + valueInDouble = convertUnits(valStr!!.toDouble(), fld, units) + mapCache[key] = valueInDouble + } + } + else valueInDouble = mapCache[key] ?: 0.0 + + return Pair(valueInDouble, strBuilder.toString()) + } + + constructor(h: String, f: String, s: String) { + header = h.trimEnd() + first = f + second = s + + initialize() + } + + constructor(tle: org.nstart.dep265.tletools.zeptomoby.core.TLE) { + header = tle.header + first = tle.first + second = tle.second + + field = tle.field + mapCache = tle.mapCache + } + + protected fun initialize() { + if (field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_NORADNUM]?.isNotEmpty() == true) return + + assert(first.isNotEmpty()) + assert(second.isNotEmpty()) + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_NORADNUM] = first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_SATNUM, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_SATNUM + ) + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_INTLDESC] = first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_INTLDESC_A, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_INTLDESC_A + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_INTLDESC_B + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_INTLDESC_C + ) + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_EPOCHYEAR] = first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_EPOCH_A, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_EPOCH_A + ) + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_EPOCHDAY] = first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_EPOCH_B, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_EPOCH_B + ) + + if (first[org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_MEANMOTIONDT] == '-') field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_MMOTIONDT] = "-0" + else field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_MMOTIONDT] = "0" + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_MMOTIONDT] += first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_MEANMOTIONDT + 1, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_MEANMOTIONDT + ) + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_MMOTIONDT2] = + org.nstart.dep265.tletools.zeptomoby.core.TLE.ProtectedStatic.expToAtof( + first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_MEANMOTIONDT2, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_MEANMOTIONDT2 + ) + ) + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_BSTAR] = + org.nstart.dep265.tletools.zeptomoby.core.TLE.ProtectedStatic.expToAtof( + first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_BSTAR, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_BSTAR + ) + ) + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_SET] = first.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_COL_ELNUM, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE1_LEN_ELNUM + ).trimStart() + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_I] = second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_INCLINATION, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_INCLINATION + ).trimStart() + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_RAAN] = second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_RAASCENDNODE, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_RAASCENDNODE + ).trimStart() + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_E] = "0." + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_E] += second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_ECCENTRICITY, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_ECCENTRICITY + ) + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_E] = field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_E.ordinal]!!.trimStart() + + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_ARGPER] = second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_ARGPERIGEE, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_ARGPERIGEE + ).trimStart() + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_M] = second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_MEANANOMALY, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_MEANANOMALY + ).trimStart() + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_MMOTION] = second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_MEANMOTION, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_MEANMOTION + ).trimStart() + field[org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields.FLD_ORBITNUM] = second.substringByLength( + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_COL_REVATEPOCH, + org.nstart.dep265.tletools.zeptomoby.core.TLE.TLEPartLength.TLE2_LEN_REVATEPOCH + ).trimStart() + } + + private fun key(u: org.nstart.dep265.tletools.zeptomoby.core.TLE.Units, f: org.nstart.dep265.tletools.zeptomoby.core.TLE.Fields): Int = (u.ordinal * 100) + f.ordinal +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Vector.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Vector.kt new file mode 100644 index 0000000..a261653 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/Vector.kt @@ -0,0 +1,31 @@ +package org.nstart.dep265.tletools.zeptomoby.core + +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.sqrt + +@Suppress("unused") +class Vector( + var x: Double = 0.0, + var y: Double = 0.0, + var z: Double = 0.0, + var w: Double = 0.0) { + + fun sub(v: Vector) { + x -= v.x + y -= v.y + z -= v.z + w -= v.w + } + + fun mul(f: Double) { + x *= f + y *= f + z *= f + w *= abs(f) + } + + fun angle(v: Vector): Double = acos(dot(v) / (magnitude() * v.magnitude())) + fun magnitude(): Double = sqrt(x*x + y*y + z*z) + fun dot(v: Vector): Double = x*v.x + y*v.y + z*v.z +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/Geo.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/Geo.kt new file mode 100644 index 0000000..5303b37 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/Geo.kt @@ -0,0 +1,78 @@ +package org.nstart.dep265.tletools.zeptomoby.core.coord + +import org.nstart.dep265.tletools.zeptomoby.core.Globals +import org.nstart.dep265.tletools.zeptomoby.core.Globals.acTan +import org.nstart.dep265.tletools.zeptomoby.core.Globals.fmod +import org.nstart.dep265.tletools.zeptomoby.core.Globals.rad2deg +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.Vector +import org.nstart.dep265.tletools.zeptomoby.core.eci.Eci +import java.lang.StringBuilder +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +open class Geo { + + constructor(eci: Eci, date: Julian) { + val coords = construct(eci.position, fmod((acTan(eci.position.y, eci.position.x) - date.toGMST()), org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi)) + + latitudeRad = coords[0] + longitudeRad = coords[1] + altitudeKm = coords[2] + } + + constructor(latRad: Double, lonRad: Double, altKm: Double) { + latitudeRad = latRad + longitudeRad = lonRad + altitudeKm = altKm + } + + var altitudeKm: Double + val latitudeRad: Double + val longitudeRad: Double + val latitudeDeg: Double + get() = rad2deg(latitudeRad) + val longitudeDeg: Double + get() = rad2deg(longitudeRad) + + override fun toString(): String { + val strBuilder = StringBuilder() + val isNorth = latitudeRad >= 0.0 + val isEast = longitudeRad >= 0.0 + + strBuilder.append(String.format("%04.3f%c", latitudeDeg, if (isNorth) 'N' else 'S')) + strBuilder.append(" ") + strBuilder.append(String.format("%05.3f%c", latitudeDeg, if (isEast) 'E' else 'W')) + strBuilder.append(" ") + strBuilder.append(String.format("%.1fm", altitudeKm * 1000.0)) + + return strBuilder.toString() + } + + protected fun construct(posEcf: Vector, theta: Double): Array { + var fTheta = fmod(theta, org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi) + if (fTheta < 0.0) fTheta += org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi + + val kmSemiMaj = org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer + + val r = sqrt(sqr(posEcf.x) + sqr(posEcf.y)) + val e2 = org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.f * (2.0 - org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.f) + var lat = acTan(posEcf.z, r) + + val delta = 1.0e-7 + var phi: Double + var c: Double + + do { + phi = lat + c = 1.0 / sqrt(1.0 - e2 * sqr(sin(phi))) + lat = acTan(posEcf.z + kmSemiMaj * c * e2 * sin(phi), r) + } + while (abs(lat - phi) > delta) + + return arrayOf(lat, fTheta, r / cos(lat) - kmSemiMaj * c) + } +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/GeoTime.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/GeoTime.kt new file mode 100644 index 0000000..4557d88 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/GeoTime.kt @@ -0,0 +1,26 @@ +package org.nstart.dep265.tletools.zeptomoby.core.coord + +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.eci.Eci +import org.nstart.dep265.tletools.zeptomoby.core.eci.EciTime + +@Suppress("unused") +class GeoTime: Geo { + + constructor(geo: Geo, d: Julian): + this(geo.latitudeRad, geo.longitudeRad, geo.altitudeKm, d) + + constructor(latRad: Double, lonRad: Double, altKm: Double, d: Julian): super(latRad, lonRad, altKm) { + date = d + } + + constructor(eci: Eci, d: Julian): super(eci, d) { + date = d + } + + constructor(eci: EciTime): super(eci, eci.date) { + date = eci.date + } + + val date: Julian +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/Topo.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/Topo.kt new file mode 100644 index 0000000..53066ab --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/Topo.kt @@ -0,0 +1,13 @@ +package org.nstart.dep265.tletools.zeptomoby.core.coord + +import org.nstart.dep265.tletools.zeptomoby.core.Globals.rad2deg + +@Suppress("unused") +open class Topo(val azimuthRad: Double, val elevationRad: Double, + val rangeKm: Double, val rangeRatekmSec: Double) { + + val azimuthDeg: Double + get() = rad2deg(azimuthRad) + val elevationDeg: Double + get() = rad2deg(elevationRad) +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/TopoTime.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/TopoTime.kt new file mode 100644 index 0000000..587c968 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/coord/TopoTime.kt @@ -0,0 +1,17 @@ +package org.nstart.dep265.tletools.zeptomoby.core.coord + +import org.nstart.dep265.tletools.zeptomoby.core.Julian + +@Suppress("unused") +class TopoTime: Topo { + + constructor(topo: Topo, d: Julian): + this(topo.azimuthRad, topo.elevationRad, topo.rangeKm, topo.rangeRatekmSec, d) + + constructor(azRad: Double, elRad: Double, range: Double ,rangeRate: Double, d: Julian) + : super(azRad, elRad, range, rangeRate) { + date = d + } + + val date: Julian +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/eci/Eci.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/eci/Eci.kt new file mode 100644 index 0000000..e895826 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/eci/Eci.kt @@ -0,0 +1,57 @@ +package org.nstart.dep265.tletools.zeptomoby.core.eci + +import org.nstart.dep265.tletools.zeptomoby.core.Globals +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.Vector +import org.nstart.dep265.tletools.zeptomoby.core.coord.Geo +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +open class Eci { + + constructor(pos: Vector, vel: Vector) { + position = pos + velocity = vel + } + + constructor(geo: Geo, date: Julian) { + val lat: Double = geo.latitudeRad + val lon: Double = geo.longitudeRad + val alt: Double = geo.altitudeKm + + val theta: Double = date.toLMST(lon) + val c: Double = 1.0 / sqrt(1.0 + org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.f * (org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.f - 2.0) * sqr(sin(lat))) + val s: Double = sqr(1.0 - org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.f) * c + val achcp: Double = (org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer * c + alt) * cos(lat) + + val x = achcp * cos(theta) // km + val y = achcp * sin(theta) // km + val z = (org.nstart.dep265.tletools.zeptomoby.core.Globals.wgs.xkmPer * s + alt) * sin(lat) // km + + position = Vector(x, y, z, + sqrt( + sqr(x) + sqr(y) + sqr(z) // range, km + ) + ) + + val mFactor: Double = org.nstart.dep265.tletools.zeptomoby.core.Globals.twoPi * (org.nstart.dep265.tletools.zeptomoby.core.Globals.omegaE / org.nstart.dep265.tletools.zeptomoby.core.Globals.secPerDay) + + val vx = -mFactor * position.y // km / sec + val vy = mFactor * position.x // km / sec + val vz = 0.0 // km / sec + + velocity = Vector(vx, vy, vz, + sqrt( + sqr(vx) + sqr(vy) // range rate km/sec^2 + ) + ) + } + + val position: Vector + val velocity: Vector + + fun scalePosVector(f: Double) = position.mul(f) + fun scaleVelVector(f: Double) = velocity.mul(f) +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/eci/EciTime.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/eci/EciTime.kt new file mode 100644 index 0000000..082e613 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/eci/EciTime.kt @@ -0,0 +1,28 @@ +package org.nstart.dep265.tletools.zeptomoby.core.eci + +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.Vector +import org.nstart.dep265.tletools.zeptomoby.core.coord.Geo +import org.nstart.dep265.tletools.zeptomoby.core.coord.GeoTime + +@Suppress("unused") +class EciTime: Eci { + + constructor(eci: Eci, d: Julian): super(eci.position, eci.velocity) { + date = d + } + + constructor(pos: Vector, vel: Vector, d: Julian): super(pos, vel) { + date = d + } + + constructor(geo: Geo, d: Julian): super(geo, d) { + date = d + } + + constructor(geo: GeoTime): super(geo, geo.date) { + date = geo.date + } + + val date: Julian +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/exceptions/DecayException.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/exceptions/DecayException.kt new file mode 100644 index 0000000..08a7319 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/exceptions/DecayException.kt @@ -0,0 +1,5 @@ +package org.nstart.dep265.tletools.zeptomoby.core.exceptions + +import org.nstart.dep265.tletools.zeptomoby.core.Julian + +open class DecayException(val decayTime: Julian, val satName: String, msg: String): PropagationException(msg) \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/exceptions/PropagationException.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/exceptions/PropagationException.kt new file mode 100644 index 0000000..4168503 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/exceptions/PropagationException.kt @@ -0,0 +1,5 @@ +package org.nstart.dep265.tletools.zeptomoby.core.exceptions + +import java.lang.Exception + +open class PropagationException(msg: String = ""): Exception(msg) \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/iau/IAU.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/iau/IAU.kt new file mode 100644 index 0000000..7bec63e --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/iau/IAU.kt @@ -0,0 +1,13 @@ +package org.nstart.dep265.tletools.zeptomoby.core.iau + +interface IAU { + + val au: Double // Astronomical unit (km) + val sr: Double // Solar radius (km) (IAU 76) +} + +object IAU76: IAU { + + override val au: Double = 149597870.0 + override val sr: Double = 696000.0 +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/wgs/WGS.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/wgs/WGS.kt new file mode 100644 index 0000000..6566002 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/core/wgs/WGS.kt @@ -0,0 +1,31 @@ +package org.nstart.dep265.tletools.zeptomoby.core.wgs + +interface WGS { + + val xkmPer: Double // Earth equatorial radius - km + val f: Double // Earth flattening + val ge: Double // Earth gravitational constant + val j2: Double // J2 harmonic + val j3: Double // J3 harmonic + val j4: Double // J4 harmonic +} + +object WGS72: WGS { + + override val xkmPer: Double = 6378.135 + override val f: Double = 1.0 / 298.26 + override val ge: Double = 398600.8 + override val j2: Double = 1.0826158e-3 + override val j3: Double = -2.53881e-6 + override val j4: Double = -1.65597e-6 +} + +object WGS84: WGS { + + override val xkmPer: Double = 6378.137 + override val f: Double = 1.0 / 298.257223563 + override val ge: Double = 398600.4418 + override val j2: Double = 1.08262998905e-3 + override val j3: Double = -2.53215306e-6 + override val j4: Double = -1.61098761e-6 +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/Orbit.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/Orbit.kt new file mode 100644 index 0000000..e2512f7 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/Orbit.kt @@ -0,0 +1,115 @@ +package org.nstart.dep265.tletools.zeptomoby.orbit + +import org.nstart.dep265.tletools.zeptomoby.core.Globals +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.TLE +import org.nstart.dep265.tletools.zeptomoby.core.eci.EciTime +import org.nstart.dep265.tletools.zeptomoby.orbit.norad.NoradBase +import org.nstart.dep265.tletools.zeptomoby.orbit.norad.NoradSDP4 +import org.nstart.dep265.tletools.zeptomoby.orbit.norad.NoradSGP4 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sqrt + +@Suppress("unused") +open class Orbit(t: TLE) { + + val inclination: Double + val eccentricity: Double + val raan: Double + val argPerigee: Double + val bStar: Double + val drag: Double + val meanMotionTle: Double + val meanAnomaly: Double + + val epoch: Julian + val satId: String + + val satName: String + get() = tle.header + val tleFirstLine: String + get() = tle.first + val tleSecondLine: String + get() = tle.second + + val semiMajor: Double + val semiMinor: Double + val meanMotion: Double + val perigee: Double + val apogee: Double + val orbitNum: Int + val period: Double + get() { + if (secPeriod < 0.0) { + secPeriod = if (meanMotion == 0.0) 0.0 + else Globals.twoPi / meanMotion * 60.0 + } + return secPeriod + } + val major: Double + get() = semiMajor * 2.0 + val minor: Double + get() = semiMinor * 2.0 + + private val tle: TLE = t + private val noradModel: NoradBase + private var secPeriod: Double + + init { + satId = tle.getField(TLE.Fields.FLD_NORADNUM).second + inclination = radGet(TLE.Fields.FLD_I).first + eccentricity = tle.getField(TLE.Fields.FLD_E).first + raan = radGet(TLE.Fields.FLD_RAAN).first + argPerigee = radGet(TLE.Fields.FLD_ARGPER).first + bStar = tle.getField(TLE.Fields.FLD_BSTAR).first / Globals.ae + drag = tle.getField(TLE.Fields.FLD_MMOTIONDT).first + meanMotionTle = tle.getField(TLE.Fields.FLD_MMOTION).first + meanAnomaly = radGet(TLE.Fields.FLD_M).first + var epochYear = tle.getField(TLE.Fields.FLD_EPOCHYEAR).first.toInt() + val epochDay = tle.getField(TLE.Fields.FLD_EPOCHDAY).first + orbitNum = tle.getField(TLE.Fields.FLD_ORBITNUM).first.toInt() + epochYear += if (epochYear < 57) 2000 else 1900 + epoch = Julian(epochYear, epochDay) + secPeriod = -1.0 + val mm = meanMotionTle + val rpmin = mm * Globals.twoPi / Globals.minPerDay + val a1 = (Globals.xke / rpmin).pow(2.0 / 3.0) + val e = eccentricity + val i = inclination + val temp = 1.5 * Globals.ck2 * (3.0 * sqr(cos(i)) - 1.0) / + (1.0 - e * e).pow(1.5) + val delta1 = temp / (a1 * a1) + val a0 = a1 * + (1.0 - delta1 * + ((1.0 / 3.0) + delta1 * + (1.0 + 134.0 / 81.0 * delta1))) + val delta0 = temp / (a0 * a0) + meanMotion = rpmin / (1.0 + delta0) + semiMajor = a0 / (1.0 - delta0) + semiMinor = semiMajor * sqrt(1.0 - (e * e)) + perigee = Globals.wgs.xkmPer * (semiMajor * (1.0 - e) - Globals.ae) + apogee = Globals.wgs.xkmPer * (semiMajor * (1.0 + e) - Globals.ae) + noradModel = if (Globals.twoPi / meanMotion >= 225) NoradSDP4(this) + else NoradSGP4(this) + } + + fun tPlusEpoch(t: Julian) = t.spanSec(epoch) + + @Deprecated("", ReplaceWith("positionEci(mpe)")) + fun position(mpe: Double) = positionEci(mpe) + + fun positionEci(mpe: Double): EciTime { + val eci = noradModel.position(mpe) + val radiusAe: Double = Globals.wgs.xkmPer / Globals.ae + + eci.scalePosVector(radiusAe) // km + eci.scaleVelVector(radiusAe * (Globals.minPerDay / 86400)) // km/sec + + return eci + } + + protected fun radGet(fld: TLE.Fields) = tle.getField(fld, TLE.Units.U_RAD) + protected fun degGet(fld: TLE.Fields) = tle.getField(fld, TLE.Units.U_DEG) +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/Satellite.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/Satellite.kt new file mode 100644 index 0000000..acebfff --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/Satellite.kt @@ -0,0 +1,24 @@ +package org.nstart.dep265.tletools.zeptomoby.orbit + +import org.nstart.dep265.tletools.zeptomoby.core.Julian +import org.nstart.dep265.tletools.zeptomoby.core.TLE + +@Suppress("unused") +class Satellite(tle: TLE, n: String? = null) { + + val name: String + val orbit: Orbit + + init { + orbit = Orbit(tle) + name = n ?: orbit.satName + } + + fun positionEci(time: Julian) = positionEci(time.spanMin(orbit.epoch)) + fun positionEci(mpe: Double) = orbit.positionEci(mpe) + + fun copy(src: Satellite) = Satellite(TLE(src.orbit.satName, src.orbit.tleFirstLine, src.orbit.tleSecondLine), name.ifEmpty { null }) + + val tleName: String + get() = orbit.satName +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradBase.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradBase.kt new file mode 100644 index 0000000..4ac74fc --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradBase.kt @@ -0,0 +1,210 @@ +package org.nstart.dep265.tletools.zeptomoby.orbit.norad + +import org.nstart.dep265.tletools.zeptomoby.core.Globals +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.Vector +import org.nstart.dep265.tletools.zeptomoby.core.eci.EciTime +import org.nstart.dep265.tletools.zeptomoby.core.exceptions.DecayException +import org.nstart.dep265.tletools.zeptomoby.core.exceptions.PropagationException +import org.nstart.dep265.tletools.zeptomoby.orbit.Orbit +import kotlin.math.* + +abstract class NoradBase(o: Orbit) { + + abstract fun position(tsince: Double): EciTime + abstract fun clone(o: Orbit): NoradBase + + protected fun finalPosition(incl: Double, omega: Double, e: Double, a: Double, + xl: Double, xnode: Double, xn: Double, tsince: Double): EciTime { + if ((e * e) > 1.0) throw PropagationException("Error in satellite data") + + val beta = sqrt(1.0 - e * e) + val axn = e * cos(omega) + var temp = 1.0 / (a * beta * beta) + + val sinip = sin(orbit.inclination) + val cosip = cos(orbit.inclination) + val aycof = 0.25 * a3ovk2 * sinip + val xlcof = (0.125 * a3ovk2 * sinip * (3.0 + 5.0 * cosip)) / + (1.0 + cosip) + val xll = temp * xlcof * axn + val aynl = temp * aycof + val xlt = xl + xll + val ayn = e * sin(omega) + aynl + + val e6a = 1.0e-6 + val capu = Globals.fmod2p(xlt - xnode) + + var temp2 = capu + var temp3 = 0.0 + var temp4 = 0.0 + var temp5 = 0.0 + var temp6 = 0.0 + var sinepw = 0.0 + var cosepw = 0.0 + var fDone = false + + for (i in 1..10) { + if (fDone) break + + sinepw = sin(temp2) + cosepw = cos(temp2) + temp3 = axn * sinepw + temp4 = ayn * cosepw + temp5 = axn * cosepw + temp6 = ayn * sinepw + + val epw = (capu - temp4 + temp3 - temp2) / (1.0 - temp5 - temp6) + temp2 + + if (abs(epw - temp2) <= e6a) fDone = true + else temp2 = epw + } + + val ecose = temp5 + temp6 + val esine = temp3 - temp4 + val elsq = axn * axn + ayn * ayn + temp = 1.0 - elsq + val pl = a * temp + val r = a * (1.0 - ecose) + var temp1 = 1.0 / r + val rdot = Globals.xke * sqrt(a) * esine * temp1 + val rfdot = Globals.xke * sqrt(pl) * temp1 + temp2 = a * temp1 + val betal = sqrt(temp) + temp3 = 1.0 / (1.0 + betal) + val cosu = temp2 * (cosepw - axn + ayn * esine * temp3) + val sinu = temp2 * (sinepw - ayn - axn * esine * temp3) + val u = Globals.acTan(sinu, cosu) + val sin2u = 2.0 * sinu * cosu + val cos2u = 2.0 * cosu * cosu - 1.0 + + temp = 1.0 / pl + temp1 = Globals.ck2 * temp + temp2 = temp1 * temp + + val cosip2 = cosip * cosip + val x3thm1 = 3.0 * cosip2 - 1.0 + val x1mth2 = 1.0 - cosip2 + val x7thm1 = 7.0 * cosip2 - 1.0 + val rk = r * (1.0 - 1.5 * temp2 * betal * x3thm1) + + 0.5 * temp1 * x1mth2 * cos2u + val uk = u - 0.25 * temp2 * x7thm1 * sin2u + val xnodek = xnode + 1.5 * temp2 * cosio * sin2u + val xinck = incl + 1.5 * temp2 * cosio * sinio * cos2u + val rdotk = rdot - xn * temp1 * x1mth2 * sin2u + val rfdotk = rfdot + xn * temp1 * (x1mth2 * cos2u + 1.5 * x3thm1) + + val sinuk = sin(uk) + val cosuk = cos(uk) + val sinik = sin(xinck) + val cosik = cos(xinck) + val sinnok = sin(xnodek) + val cosnok = cos(xnodek) + val xmx = -sinnok * cosik + val xmy = cosnok * cosik + val ux = xmx * sinuk + cosnok * cosuk + val uy = xmy * sinuk + sinnok * cosuk + val uz = sinik * sinuk + val vx = xmx * cosuk - cosnok * sinuk + val vy = xmy * cosuk - sinnok * sinuk + val vz = sinik * cosuk + + val x = rk * ux + val y = rk * uy + val z = rk * uz + + val vecPos = Vector(x, y, z) + val altKm = (vecPos.magnitude() * (Globals.wgs.xkmPer / Globals.ae)) + + if (altKm < Globals.wgs.xkmPer) { + val decayTime = orbit.epoch + + decayTime.addMin(tsince) + throw DecayException(decayTime, "${orbit.satName}#${orbit.satId}", "altitude < xkmper") + } + + val xdot = rdotk * ux + rfdotk * vx + val ydot = rdotk * uy + rfdotk * vy + val zdot = rdotk * uz + rfdotk * vz + + val vecVel = Vector(xdot, ydot, zdot) + + val gmt = orbit.epoch + gmt.addMin(tsince) + + return EciTime(vecPos, vecVel, gmt) + } + + val orbit: Orbit = o + + val cosio: Double = cos(orbit.inclination); val sinio: Double = sin(orbit.inclination) + val betao2: Double; val betao: Double + var s4: Double + protected set + var qoms24: Double + protected set + val tsi: Double; val eta: Double + val eeta: Double; val coef: Double; val coef1: Double + val c1: Double; val c3: Double; val c4: Double + val a3ovk2: Double; val xmdot: Double; val omgdot: Double + val xnodot: Double; val xnodcf: Double; val t2cof: Double + + init { + val theta2 = cosio * cosio + val x3thm1 = 3.0 * theta2 - 1.0 + val eosq = sqr(orbit.eccentricity) + betao2 = 1.0 - eosq + betao = sqrt(betao2) + val rp = orbit.semiMajor * (1.0 - orbit.eccentricity) + val perigee = (rp - 1.0) * Globals.wgs.xkmPer + s4 = Globals.s + qoms24 = Globals.qoms2t + if (perigee < 156.0) { + s4 = perigee - 78.0 + if (perigee <= 98.0) s4 = 20.0 + + qoms24 = ((120.0 - s4) * Globals.ae / Globals.wgs.xkmPer).pow(4) + s4 = s4 / Globals.wgs.xkmPer + Globals.ae + } + val pinvsq = 1.0 / (sqr(orbit.semiMajor) * sqr(betao2)) + tsi = 1.0 / (orbit.semiMajor - s4) + eta = orbit.semiMajor * orbit.eccentricity * tsi + eeta = orbit.eccentricity * eta + val etasq = eta * eta + val psisq = abs(1.0 - etasq) + coef = qoms24 * tsi.pow(4.0) + coef1 = coef / psisq.pow(3.5) + val c2 = coef1 * orbit.meanMotion * + (orbit.semiMajor * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq)) + + 0.75 * Globals.ck2 * tsi / psisq * x3thm1 * + (8.0 + 3.0 * etasq * (8.0 + etasq))) + c1 = orbit.bStar * c2 + a3ovk2 = -Globals.xj3 / Globals.ck2 * Globals.ae.pow(3.0) + c3 = coef * tsi * a3ovk2 * orbit.meanMotion * Globals.ae * sinio / orbit.eccentricity + val x1mth2 = 1.0 - theta2 + c4 = 2.0 * orbit.meanMotion * coef1 * orbit.semiMajor * betao2 * + (eta * (2.0 + 0.5 * etasq) + + orbit.eccentricity * (0.5 + 2.0 * etasq) - + 2.0 * Globals.ck2 * tsi / (orbit.semiMajor * psisq) * + (-3.0 * x3thm1 * (1.0 - 2.0 * eeta + etasq * (1.5 - 0.5 * eeta)) + + 0.75 * x1mth2 * + (2.0 * etasq - eeta * (1.0 + etasq)) * + cos(2.0 * orbit.argPerigee))) + val theta4 = theta2 * theta2 + val temp1 = 3.0 * Globals.ck2 * pinvsq * orbit.meanMotion + val temp2 = temp1 * Globals.ck2 * pinvsq + val temp3 = 1.25 * Globals.ck4 * pinvsq * pinvsq * orbit.meanMotion + xmdot = orbit.meanMotion + 0.5 * temp1 * betao * x3thm1 + + 0.0625 * temp2 * betao * + (13.0 - 78.0 * theta2 + 137.0 * theta4) + val x1m5th = 1.0 - 5.0 * theta2 + omgdot = -0.5 * temp1 * x1m5th + 0.0625 * temp2 * + (7.0 - 114.0 * theta2 + 395.0 * theta4) + + temp3 * (3.0 - 36.0 * theta2 + 49.0 * theta4) + val xhdot1 = -temp1 * cosio + xnodot = xhdot1 + (0.5 * temp2 * (4.0 - 19.0 * theta2) + + 2.0 * temp3 * (3.0 - 7.0 * theta2)) * cosio + xnodcf = 3.5 * betao2 * xhdot1 * c1 + t2cof = 1.5 * c1 + } +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradSDP4.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradSDP4.kt new file mode 100644 index 0000000..fec08c6 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradSDP4.kt @@ -0,0 +1,616 @@ +package org.nstart.dep265.tletools.zeptomoby.orbit.norad + +import org.nstart.dep265.tletools.zeptomoby.core.Globals +import org.nstart.dep265.tletools.zeptomoby.core.Globals.acTan +import org.nstart.dep265.tletools.zeptomoby.core.Globals.fmod2p +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.eci.EciTime +import org.nstart.dep265.tletools.zeptomoby.orbit.Orbit +import kotlin.math.* + +open class NoradSDP4(o: Orbit): NoradBase(o) { + + companion object Constants { + const val zes: Double = 0.01675 + const val zel: Double = 0.05490 + const val zns: Double = 1.19459e-05 + const val znl: Double = 1.5835218e-04 + const val thdt: Double = 4.3752691e-03 + } + + protected data class DeepSecularVars(val xmdf: Double, val omgadf: Double, + val xnode: Double, val em: Double, + val xinc: Double, val xn: Double) + + protected data class DeepPeriodicsVars(val e: Double, val xinc: Double, + val omgadf: Double, val xnode: Double, + val xmam: Double) + + protected data class DeepDotTermsVars(val xndot: Double, val xnddt: Double, val xldot: Double) + + protected var dpE3 = 0.0; protected var dpEe2 = 0.0; protected var dpSe2 = 0.0 + protected var dpSe3 = 0.0; protected var dpSgh2 = 0.0; protected var dpSgh3 = 0.0 + protected var dpSgh4 = 0.0; protected var dpSh2 = 0.0; protected var dpSh3 = 0.0 + protected var dpSi2 = 0.0; protected var dpSi3 = 0.0; protected var dpSl2 = 0.0 + protected var dpSl3 = 0.0; protected var dpSl4 = 0.0; protected var dpXgh2 = 0.0 + protected var dpXgh3 = 0.0; protected var dpXgh4 = 0.0; protected var dpXh2 = 0.0 + protected var dpXh3 = 0.0; protected var dpXi2 = 0.0; protected var dpXi3 = 0.0 + protected var dpXl2 = 0.0; protected var dpXl3 = 0.0; protected var dpXl4 = 0.0 + protected val dpZmol: Double; protected val dpZmos: Double + + protected var dpAtime: Double; protected var dpD2201 = 0.0; protected var dpD2211 = 0.0 + protected var dpD3210 = 0.0; protected var dpD3222 = 0.0; protected var dpD4410 = 0.0 + protected var dpD4422 = 0.0; protected var dpD5220 = 0.0; protected var dpD5232 = 0.0 + protected var dpD5421 = 0.0; protected var dpD5433 = 0.0; protected var dpDel1 = 0.0 + protected var dpDel2 = 0.0; protected var dpDel3 = 0.0; protected var dpSse: Double = 0.0 + protected var dpSsg: Double = 0.0; protected var dpSsh: Double = 0.0; protected var dpSsi: Double = 0.0 + protected var dpSsl: Double = 0.0; protected val dpStep2: Double; protected val dpStepn: Double + protected val dpStepp: Double; protected val dpThgr: Double; protected val dpXfact: Double + protected var dpXlamo: Double = 0.0; protected var dpXli: Double; protected var dpXni: Double + + var gpReso: Boolean + var gpSync: Boolean + + init { + val sinArg = sin(orbit.argPerigee) + val cosArg = cos(orbit.argPerigee) + val eqsq = sqr(orbit.eccentricity) + + val jd = orbit.epoch + dpThgr = jd.toGMST() + + val eq = orbit.eccentricity + val aqnv = 1.0 / orbit.semiMajor + val xmao = orbit.meanAnomaly + val xpidot = omgdot + xnodot + val sinQ = sin(orbit.raan) + val cosQ = cos(orbit.raan) + + val day = jd.fromJan0_12h_1900() + val dpiXnodce = 4.5236020 - 9.2422029e-4 * day + val dpiStem = sin(dpiXnodce) + val dpiCtem = cos(dpiXnodce) + val dpiZcosil = 0.91375164 - 0.03568096 * dpiCtem + val dpiZsinil = sqrt(1.0 - dpiZcosil * dpiZcosil) + val dpiZsinhl = 0.089683511 *dpiStem / dpiZsinil + val dpiZcoshl = sqrt(1.0 - dpiZsinhl * dpiZsinhl) + val dpiC = 4.7199672 + 0.22997150 * day + val dpiGam = 5.8351514 + 0.0019443680 * day + + dpZmol = fmod2p(dpiC - dpiGam) + + var dpiZx = 0.39785416 * dpiStem / dpiZsinil + val dpiZy = dpiZcoshl * dpiCtem + 0.91744867 * dpiZsinhl * dpiStem + + dpiZx = acTan(dpiZx,dpiZy) + dpiGam - dpiXnodce + + val dpiZcosgl = cos(dpiZx) + val dpiZsingl = sin(dpiZx) + + dpZmos = fmod2p(6.2565837 + 0.017201977 * day) + + val zcosis = 0.91744867 + val zsinis = 0.39785416 + val c1ss = 2.9864797e-06 + val zsings = -0.98088458 + val zcosgs = 0.1945905 + + var zcosg = zcosgs + var zsing = zsings + var zcosi = zcosis + var zsini = zsinis + var zcosh = cosQ + var zsinh = sinQ + var cc = c1ss + var zn = zns + var ze = zes + val xnoi = 1.0 / orbit.meanMotion + + var se = 0.0; var si = 0.0; var sl = 0.0 + var sgh = 0.0; var sh = 0.0 + + for (pass in 1..2) { + val a1 = zcosg * zcosh + zsing * zcosi * zsinh + val a3 = -zsing * zcosh + zcosg * zcosi * zsinh + val a7 = -zcosg * zsinh + zsing * zcosi * zcosh + val a8 = zsing * zsini + val a9 = zsing * zsinh + zcosg * zcosi * zcosh + val a10 = zcosg * zsini + + val a2 = cosio * a7 + sinio * a8 + val a4 = cosio * a9 + sinio * a10 + val a5 = -sinio * a7 + cosio * a8 + val a6 = -sinio * a9 + cosio * a10 + val x1 = a1 * cosArg + a2 * sinArg + val x2 = a3 * cosArg + a4 * sinArg + val x3 = -a1 * sinArg + a2 * cosArg + val x4 = -a3 * sinArg + a4 * cosArg + val x5 = a5 * sinArg + val x6 = a6 * sinArg + val x7 = a5 * cosArg + val x8 = a6 * cosArg + val z31 = 12.0 * x1 * x1 - 3.0 * x3 * x3 + val z32 = 24.0 * x1 * x2 - 6.0 * x3 * x4 + val z33 = 12.0 * x2 * x2 - 3.0 * x4 * x4 + var z1 = 3.0 * (a1 * a1 + a2 * a2) + z31 * eqsq + var z2 = 6.0 * (a1 * a3 + a2 * a4) + z32 * eqsq + var z3 = 3.0 * (a3 * a3 + a4 * a4) + z33 * eqsq + val z11 = -6.0 * a1 * a5 + eqsq*(-24.0 * x1 * x7 - 6.0 * x3 * x5) + val z12 = -6.0 * (a1 * a6 + a3 * a5) + + eqsq * (-24.0 * (x2 * x7 + x1 * x8) - 6.0 * (x3 * x6 + x4 * x5)) + val z13 = -6.0 * a3 * a6 + eqsq * (-24.0 * x2 * x8 - 6.0 * x4 * x6) + val z21 = 6.0 * a2 * a5 + eqsq * (24.0 * x1 * x5 - 6.0 * x3 * x7) + val z22 = 6.0*(a4 * a5 + a2 * a6) + + eqsq * (24.0 * (x2 * x5 + x1 * x6) - 6.0 * (x4 * x7 + x3 * x8)) + val z23 = 6.0 * a4 * a6 + eqsq*(24.0 * x2 * x6 - 6.0 * x4 * x8) + z1 += z1 + betao2 * z31 + z2 += z2 + betao2 * z32 + z3 += z3 + betao2 * z33 + val s3 = cc * xnoi + val s2 = -0.5 * s3 / betao + val s4 = s3 * betao + val s1 = -15.0 * eq * s4 + val s5 = x1 * x3 + x2 * x4 + val s6 = x2 * x3 + x1 * x4 + val s7 = x2 * x4 - x1 * x3 + se = s1 * zn * s5 + si = s2 * zn * (z11 + z13) + sl = -zn * s3 * (z1 + z3 - 14.0 - 6.0 * eqsq) + sgh = s4 * zn * (z31 + z33 - 6.0) + sh = -zn * s2 * (z21 + z23) + + if (orbit.inclination < 5.2359877e-2) sh = 0.0 + + dpEe2 = 2.0 * s1 * s6 + dpE3 = 2.0 * s1 * s7 + dpXi2 = 2.0 * s2 * z12 + dpXi3 = 2.0 * s2 * (z13 - z11) + dpXl2 = -2.0 * s3 * z2 + dpXl3 = -2.0 * s3 * (z3 - z1) + dpXl4 = -2.0 * s3 * (-21.0 - 9.0 * eqsq) * ze + dpXgh2 = 2.0 * s4 * z32 + dpXgh3 = 2.0 * s4 * (z33 - z31) + dpXgh4 = -18.0 * s4 * ze + dpXh2 = -2.0 * s2 * z22 + dpXh3 = -2.0 * s2 * (z23 - z21) + + if (pass == 1) { + dpSse = se + dpSsi = si + dpSsl = sl + dpSsh = sh / sinio + dpSsg = sgh - cosio * dpSsh + dpSe2 = dpEe2 + dpSi2 = dpXi2 + dpSl2 = dpXl2 + dpSgh2 = dpXgh2 + dpSh2 = dpXh2 + dpSe3 = dpE3 + dpSi3 = dpXi3 + dpSl3 = dpXl3 + dpSgh3 = dpXgh3 + dpSh3 = dpXh3 + dpSl4 = dpXl4 + dpSgh4 = dpXgh4 + zcosg = dpiZcosgl + zsing = dpiZsingl + zcosi = dpiZcosil + zsini = dpiZsinil + zcosh = dpiZcoshl * cosQ + dpiZsinhl * sinQ + zsinh = sinQ * dpiZcoshl - cosQ * dpiZsinhl + zn = znl + + val c1l = 4.7968065e-07 + cc = c1l + ze = zel + } + } + + dpSse += se + dpSsi += si + dpSsl += sl + dpSsg = dpSsg + sgh - cosio / sinio * sh + dpSsh += sh / sinio + + gpReso = false + gpSync = false + + val g310: Double + val f220: Double + var bfact = 0.0 + + if ((orbit.meanMotion > 0.0034906585) && + (orbit.meanMotion < 0.0052359877)) { + gpReso = true + gpSync = true + + val g200 = 1.0 + eqsq * (-2.5 + 0.8125 * eqsq) + g310 = 1.0 + 2.0 * eqsq + + val g300 = 1.0 + eqsq * (-6.0 + 6.60937 * eqsq) + f220 = 0.75 * (1.0 + cosio) * (1.0 + cosio) + + val f311 = 0.9375 * sinio * sinio * (1.0 + 3 * cosio) - 0.75 * (1.0 + cosio) + var f330 = 1.0 + cosio + + val q22 = 1.7891679e-06 + val q31 = 2.1460748e-06 + val q33 = 2.2123015e-07 + + f330 *= 1.875 * f330 * f330 + dpDel1 = 3.0 * orbit.meanMotion * orbit.meanMotion * aqnv * aqnv + dpDel2 = 2.0 * dpDel1 * f220 * g200 * q22 + dpDel3 = 3.0 * dpDel1 * f330 * g300 * q33 * aqnv + dpDel1 *= f311 * g310 * q31 * aqnv + dpXlamo = xmao + orbit.raan + orbit.argPerigee - dpThgr + bfact = xmdot + xpidot - thdt + bfact += dpSsl + dpSsg + dpSsh + } + else if ((orbit.meanMotion >= 8.26e-03) && + (orbit.meanMotion <= 9.24e-03) && + (eq >= 0.5)) { + gpReso = true + + val eoc = eq * eqsq + val g201 = -0.306 - (eq - 0.64) * 0.440 + + val g211: Double; val g322: Double + val g410: Double; val g422: Double + val g520: Double + + if (eq <= 0.65) { + g211 = 3.616 - 13.247 * eq + 16.290 * eqsq + g310 = -19.302 + 117.390 * eq - 228.419 * eqsq + 156.591 * eoc + g322 = -18.9068 + 109.7927 * eq - 214.6334 * eqsq + 146.5816 * eoc + g410 = -41.122 + 242.694 * eq - 471.094 * eqsq + 313.953 * eoc + g422 = -146.407 + 841.880 * eq - 1629.014 * eqsq + 1083.435 * eoc + g520 = -532.114 + 3017.977 * eq - 5740.0 * eqsq + 3708.276 * eoc + } + else { + g211 = -72.099 + 331.819 * eq - 508.738 * eqsq + 266.724 * eoc + g310 = -346.844 + 1582.851 * eq - 2415.925 * eqsq + 1246.113 * eoc + g322 = -342.585 + 1554.908 * eq - 2366.899 * eqsq + 1215.972 * eoc + g410 = -1052.797 + 4758.686 * eq - 7193.992 * eqsq + 3651.957 * eoc + g422 = -3581.69 + 16178.11 * eq - 24462.77 * eqsq + 12422.52 * eoc + + g520 = if (eq <= 0.715) 1464.74 - 4664.75 * eq + 3763.64 * eqsq + else -5149.66 + 29936.92 * eq - 54087.36 * eqsq + 31324.56 * eoc + + } + + val g533: Double + val g521: Double + val g532: Double + + if (eq < 0.7) { + g533 = -919.2277 + 4988.61 * eq - 9064.77 * eqsq + 5542.21 * eoc + g521 = -822.71072 + 4568.6173 * eq - 8491.4146 * eqsq + 5337.524 * eoc + g532 = -853.666 + 4690.25 * eq - 8624.77 * eqsq + 5341.4 * eoc + } + else { + g533 = -37995.78 + 161616.52 * eq - 229838.2 * eqsq + 109377.94 * eoc + g521 = -51752.104 + 218913.95 * eq - 309468.16 * eqsq + 146349.42 * eoc + g532 = -40023.88 + 170470.89 * eq - 242699.48 * eqsq + 115605.82 * eoc + } + + val sini2 = sqr(sinio) + val theta2 = sqr(cosio) + + f220 = 0.75 * (1.0 + 2.0 * cosio + theta2) + + val root22 = 1.7891679e-06 + val root32 = 3.7393792e-07 + val root44 = 7.3636953e-09 + val root52 = 1.1428639e-07 + val root54 = 2.1765803e-09 + + val f221 = 1.5 * sini2 + val f321 = 1.875 * sinio * (1.0 - 2.0 * cosio - 3.0 * theta2) + val f322 = -1.875 * sinio * (1.0 + 2.0 * cosio - 3.0 * theta2) + val f441 = 35.0 * sini2 * f220 + val f442 = 39.3750 * sini2 * sini2 + val f522 = 9.84375 * sinio * (sini2 * (1.0 - 2.0 * cosio - 5.0 * theta2) + + 0.33333333*(-2.0 + 4.0 * cosio + 6.0 * theta2)) + val f523 = sinio * (4.92187512 * sini2 * (-2.0 - 4.0 * cosio + 10.0 * theta2) + + 6.56250012 * (1.0 + 2.0 * cosio - 3.0 * theta2)) + val f542 = 29.53125 * sinio * ( 2.0 - 8.0 * cosio + theta2 * (-12.0 + 8.0 * cosio + 10.0 * theta2)) + val f543 = 29.53125 * sinio * (-2.0 - 8.0 * cosio + theta2 * ( 12.0 + 8.0 * cosio - 10.0 * theta2)) + val xno2 = orbit.meanMotion * orbit.meanMotion + val ainv2 = aqnv * aqnv + var temp1 = 3.0 * xno2 * ainv2 + var temp = temp1 * root22 + + dpD2201 = temp * f220 * g201 + dpD2211 = temp * f221 * g211 + temp1 *= aqnv + temp = temp1 * root32 + dpD3210 = temp * f321 * g310 + dpD3222 = temp * f322 * g322 + temp1 *= aqnv + temp = 2.0 * temp1 * root44 + dpD4410 = temp * f441 * g410 + dpD4422 = temp * f442 * g422 + temp1 *= aqnv + temp = temp1 * root52 + dpD5220 = temp * f522 * g520 + dpD5232 = temp * f523 * g532 + temp = 2.0 * temp1 * root54 + dpD5421 = temp * f542 * g521 + dpD5433 = temp * f543 * g533 + dpXlamo = xmao + orbit.raan + orbit.raan - dpThgr - dpThgr + bfact = xmdot + xnodot + xnodot - thdt - thdt + bfact += dpSsl + dpSsh + dpSsh + } + + if (gpReso || gpSync) { + dpXfact = bfact - orbit.meanMotion + dpXli = dpXlamo + dpXni = orbit.meanMotion + dpAtime = 0.0 + dpStepp = 720.0 + dpStepn = -720.0 + dpStep2 = 259200.0 + } + else { + dpXfact = 0.0 + dpXli = 0.0 + dpXni = 0.0 + dpAtime = 0.0 + dpStepp = 0.0 + dpStepn = 0.0 + dpStep2 = 0.0 + } + } + + protected fun deepSecular(xmdf: Double, omgadf: Double, xnode: Double, + xn: Double, tsince: Double): DeepSecularVars { + var xmdfLocal = xmdf + dpSsl * tsince + var omgadfLocal = omgadf + dpSsg * tsince + var xnodeLocal = xnode + dpSsh * tsince + val emmLocal = orbit.eccentricity + dpSse * tsince + var xinccLocal = orbit.inclination + dpSsi * tsince + + if (xinccLocal < 0.0) { + xinccLocal *= -1 + xnodeLocal += Globals.pi + omgadfLocal -= Globals.pi + } + + if (!gpReso) return DeepSecularVars(xmdfLocal, omgadfLocal, xnodeLocal, + emmLocal, xinccLocal, xn) + + val xnddt: Double + val xndot: Double + val xldot: Double + var delt = 0.0 + var fDone = false + + while (!fDone) { + if ((dpAtime == 0.0) || + ((tsince >= 0.0) && (dpAtime < 0.0)) || + ((tsince < 0.0) && (dpAtime >= 0.0))) { + delt = if (tsince < 0) dpStepn else dpStepp + + dpAtime = 0.0 + dpXni = orbit.meanMotion + dpXli = dpXlamo + + fDone = true + } + else { + if (abs(tsince) < abs(dpAtime)) { + delt = if (tsince >= 0.0) dpStepn else dpStepp + deepCalcIntegrator(delt) + } + else { + delt = if (tsince > 0.0) dpStepp else dpStepn + fDone = true + } + } + } + + while (abs(tsince - dpAtime) >= dpStepp) deepCalcIntegrator(delt) + + val ft = tsince - dpAtime + + val vars = deepCalcDotTerms() + xndot = vars.xndot + xnddt = vars.xnddt + xldot = vars.xldot + + val xnnLocal = dpXni + xndot * ft + xnddt * ft * ft * 0.5 + val xl = dpXli + xldot * ft + xndot * ft * ft * 0.5 + val temp = -xnodeLocal + dpThgr + tsince * thdt + + xmdfLocal = xl - omgadfLocal + temp + if (!gpSync) xmdfLocal = xl + temp + temp + + return DeepSecularVars(xmdfLocal, omgadfLocal, xnodeLocal, emmLocal, xinccLocal, xnnLocal) + } + + protected fun deepCalcDotTerms(): DeepDotTermsVars { + val fasx2 = 0.13130908 + val fasx4 = 2.8843198 + val fasx6 = 0.37448087 + + val xndotLocal: Double + var xnddtLocal: Double + + if (gpSync) { + xndotLocal = dpDel1 * sin(dpXli - fasx2) + + dpDel2 * sin(2.0 * (dpXli - fasx4)) + + dpDel3 * sin(3.0 * (dpXli - fasx6)) + xnddtLocal = dpDel1 * cos(dpXli - fasx2) + + 2.0 * dpDel2 * cos(2.0 * (dpXli - fasx4)) + + 3.0 * dpDel3 * cos(3.0 * (dpXli - fasx6)) + } + else { + val g22 = 5.7686396 + val g32 = 0.95240898 + val g44 = 1.8014998 + val g52 = 1.0508330 + val g54 = 4.4108898 + + val xomi = orbit.argPerigee + omgdot * dpAtime + val x2omi = xomi + xomi + val x2li = dpXli + dpXli + + xndotLocal = dpD2201 * sin(x2omi + dpXli - g22) + + dpD2211 * sin(dpXli - g22) + + dpD3210 * sin( xomi + dpXli - g32) + + dpD3222 * sin(-xomi + dpXli - g32) + + dpD4410 * sin(x2omi + x2li - g44) + + dpD4422 * sin(x2li - g44) + + dpD5220 * sin( xomi + dpXli - g52) + + dpD5232 * sin(-xomi + dpXli - g52) + + dpD5421 * sin( xomi + x2li - g54) + + dpD5433 * sin(-xomi + x2li - g54) + + xnddtLocal = dpD2201 * cos(x2omi + dpXli - g22) + + dpD2211 * cos(dpXli - g22) + + dpD3210 * cos( xomi + dpXli - g32) + + dpD3222 * cos(-xomi + dpXli - g32) + + dpD5220 * cos( xomi + dpXli - g52) + + dpD5232 * cos(-xomi + dpXli - g52) + + 2.0 * (dpD4410 * cos(x2omi + x2li - g44) + + dpD4422 * cos(x2li - g44) + + dpD5421 * cos( xomi + x2li - g54) + + dpD5433 * cos(-xomi + x2li - g54)) + } + + val xldotLocal = dpXni + dpXfact + xnddtLocal *= xldotLocal + + return DeepDotTermsVars(xndotLocal, xnddtLocal, xldotLocal) + } + + protected fun deepCalcIntegrator(delt: Double): DeepDotTermsVars { + val vars = deepCalcDotTerms() + + dpXli += vars.xldot * delt + vars.xndot * dpStep2 + dpXni += vars.xndot * delt + vars.xnddt * dpStep2 + dpAtime += delt + + return vars + } + + protected fun deepPeriodics(e: Double, xinc: Double, + omgadf: Double, xnode: Double, + xmam: Double, tsince: Double): DeepPeriodicsVars{ + val sinis = sin(xinc) + val cosis = cos(xinc) + + val sghs: Double + val shs: Double + val sh1: Double + val pe: Double + val pinc: Double + val pl: Double + val sghl: Double + + var zm = dpZmos + zns * tsince + var zf = zm + 2.0 * zes * sin(zm) + var sinzf = sin(zf) + var f2 = 0.5 * sinzf * sinzf - 0.25 + var f3 = -0.5 * sinzf * cos(zf) + val ses = dpSe2 * f2 + dpSe3 * f3 + val sis = dpSi2 * f2 + dpSi3 * f3 + val sls = dpSl2 * f2 + dpSl3 * f3 + dpSl4 * sinzf + + sghs = dpSgh2 * f2 + dpSgh3 * f3 + dpSgh4 * sinzf + shs = dpSh2 * f2 + dpSh3 * f3 + zm = dpZmol + znl * tsince + zf = zm + 2.0 * zel * sin(zm) + sinzf = sin(zf) + f2 = 0.5 * sinzf * sinzf - 0.25 + f3 = -0.5 * sinzf * cos(zf) + + val sel = dpEe2 * f2 + dpE3 * f3 + val sil = dpXi2 * f2 + dpXi3 * f3 + val sll = dpXl2 * f2 + dpXl3 * f3 + dpXl4 * sinzf + + sghl = dpXgh2 * f2 + dpXgh3 * f3 + dpXgh4 * sinzf + sh1 = dpXh2 * f2 + dpXh3 * f3 + pe = ses + sel + pinc = sis + sil + pl = sls + sll + + var pgh = sghs + sghl + var ph = shs + sh1 + + val xinccLocal = xinc + pinc + val eLocal = e + pe + val omgadfLocal: Double + val xnodeLocal: Double + val xmamLocal: Double + + if (orbit.inclination >= 0.2) { + ph /= sinio + pgh -= cosio * ph + omgadfLocal = omgadf + pgh + xnodeLocal = xnode + ph + xmamLocal = xmam + pl + } + else { + val sinok = sin(xnode) + val cosok = cos(xnode) + var alfdp = sinis * sinok + var betdp = sinis * cosok + val dalf = ph * cosok + pinc * cosis * sinok + val dbet = -ph * sinok + pinc * cosis * cosok + + alfdp += dalf + betdp += dbet + + var xls = xmam + omgadf + cosis * xnode + val dls = pl + pgh - pinc * xnode * sinis + + xls += dls + xnodeLocal = acTan(alfdp, betdp) + xmamLocal = xmam + pl + omgadfLocal = xls - xmamLocal - cos(xinccLocal) * xnodeLocal + } + + return DeepPeriodicsVars(eLocal, xinccLocal, omgadfLocal, xnodeLocal, xmamLocal) + } + + override fun position(tsince: Double): EciTime { + var xmdf = orbit.meanAnomaly + xmdot * tsince + var omgadf = orbit.argPerigee + omgdot * tsince + val xnoddf = orbit.raan + xnodot * tsince + val tsq = tsince * tsince + var xnode = xnoddf + xnodcf * tsq + val tempa = 1.0 - c1 * tsince + val tempe = orbit.bStar * c4 * tsince + val templ = t2cof * tsq + var xn = orbit.meanMotion + val em: Double + var xinc: Double + + val vars1 = deepSecular(xmdf, omgadf, xnode, xn, tsince) + xmdf = vars1.xmdf + omgadf = vars1.omgadf + xnode = vars1.xnode + xn = vars1.xn + em = vars1.em + xinc = vars1.xinc + + val a = (Globals.xke / xn).pow(2.0 / 3.0) * sqr(tempa) + var e = em - tempe + var xmam = xmdf + orbit.meanMotion * templ + + val vars2 = deepPeriodics(e, xinc, omgadf, xnode, xmam, tsince) + e = vars2.e + xinc = vars2.xinc + omgadf = vars2.omgadf + xnode = vars2.xnode + xmam = vars2.xmam + + val xl = xmam + omgadf + xnode + xn = Globals.xke / a.pow(1.5) + + return finalPosition(xinc, omgadf, e, a, xl, xnode, xn, tsince) + } + + override fun clone(o: Orbit): NoradBase = NoradSDP4(o) +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradSGP4.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradSGP4.kt new file mode 100644 index 0000000..bfbfaa0 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/tle_tools/zeptomoby/orbit/norad/NoradSGP4.kt @@ -0,0 +1,89 @@ +package org.nstart.dep265.tletools.zeptomoby.orbit.norad + +import org.nstart.dep265.tletools.zeptomoby.core.Globals +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.nstart.dep265.tletools.zeptomoby.core.eci.EciTime +import org.nstart.dep265.tletools.zeptomoby.orbit.Orbit +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin + +open class NoradSGP4(o: Orbit): NoradBase(o) { + protected val c5: Double + protected val omgcof: Double + protected val xmcof: Double + protected val delmo: Double + protected val sinmo: Double + + init { + val etasq: Double = eta * eta + + c5 = 2.0 * coef1 * orbit.semiMajor * betao2 * (1.0 + 2.75 * (etasq + eeta) + eeta * etasq) + omgcof = orbit.bStar * c3 * cos(orbit.argPerigee) + xmcof = -(2.0 / 3.0) * coef * orbit.bStar * Globals.ae / eeta + delmo = (1.0 + eta * cos(orbit.meanAnomaly)).pow(3.0) + sinmo = sin(orbit.meanAnomaly) + } + + override fun position(tsince: Double): EciTime { + var isImp = false + + if ((orbit.semiMajor * (1.0 - orbit.eccentricity) / Globals.ae) < + (220.0 / Globals.wgs.xkmPer + Globals.ae)) isImp = true + + var d2 = 0.0 + var d3 = 0.0 + var d4 = 0.0 + + var t3cof = 0.0 + var t4cof = 0.0 + var t5cof = 0.0 + + if (!isImp) { + val c1sq: Double = c1 * c1 + d2 = 4.0 * orbit.semiMajor * tsi * c1sq + + val temp: Double = d2 * tsi * c1 / 3.0 + d3 = (17.0 * orbit.semiMajor + s4) * temp + d4 = 0.5 * temp * orbit.semiMajor * tsi * + (221.0 * orbit.semiMajor + 31.0 * s4) * c1 + t3cof = d2 + 2.0 * c1sq + t4cof = 0.25 * (3.0 * d3 + c1 * (12.0 * d2 + 10.0 * c1sq)) + t5cof = 0.2 * (3.0 * d4 + 12.0 * c1 * d3 + 6.0 * + d2 * d2 + 15.0 * c1sq * (2.0 * d2 + c1sq)) + } + + val xmdf: Double = orbit.meanAnomaly + xmdot * tsince + val omgadf: Double = orbit.argPerigee + omgdot * tsince + val xnoddf: Double = orbit.raan + xnodot * tsince + var omega = omgadf + var xmp = xmdf + val tsq = tsince * tsince + val xnode: Double = xnoddf + xnodcf * tsq + var tempa: Double = 1.0 - c1 * tsince + var tempe: Double = orbit.bStar * c4 * tsince + var templ: Double = t2cof * tsq + + if (!isImp) { + val delomg: Double = omgcof * tsince + val delm: Double = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo) + val temp = delomg + delm + xmp = xmdf + temp + omega = omgadf - temp + val tcube = tsq * tsince + val tfour = tsince * tcube + tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour + tempe += orbit.bStar * c5 * (sin(xmp) - sinmo) + templ += t3cof * tcube + tfour * (t4cof + tsince * t5cof) + } + + val a: Double = orbit.semiMajor * sqr(tempa) + val e: Double = orbit.eccentricity - tempe + val xl: Double = xmp + omega + xnode + orbit.meanMotion * templ + val xn: Double = Globals.xke / a.pow(1.5) + + return finalPosition(orbit.inclination, omgadf, e, a, xl, xnode, xn, tsince) + } + + override fun clone(o: Orbit): NoradBase = NoradSGP4(o) +} \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/types/Errors.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/types/Errors.kt new file mode 100644 index 0000000..a0c144e --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/types/Errors.kt @@ -0,0 +1,44 @@ +package ballistics.types + +/** + * Код завершения расчета + */ +enum class BallisticsError(val value: Int) { + OK(0), + UNKNOWN_ERR(1), + + EMPTY_NU(3), + H_MINIMUM_ERROR(4), + EMPTY_ORBITAL_POINTS(5), + EMPTY_FLIGHTLINE_POINTS(6), + ORB_POINTS_INTERVAL_ERROR(7), + STEPPER_ERROR(8), + + EMPTY_PPI_LIST(9), + ; + + companion object { + val map = BallisticsError.values().associate { it.value to it } + + fun fromInt( + value: Int, + defValue: BallisticsError = BallisticsError.OK, + ): BallisticsError { + return map[value] ?: defValue + } + } +} + +fun printError(err: BallisticsError): String { + return when (err) { + BallisticsError.OK -> "Успешно" + BallisticsError.UNKNOWN_ERR -> "нераспознанная ошибка" + BallisticsError.H_MINIMUM_ERROR -> "высота достигла минимального значения" + BallisticsError.EMPTY_NU -> "пустой массив начальных условий" + BallisticsError.EMPTY_ORBITAL_POINTS -> "не рассчитаны точки орбиты" + BallisticsError.ORB_POINTS_INTERVAL_ERROR -> "интервал расчета не пренадлежит интервалу расчета точек орбиты" + BallisticsError.EMPTY_PPI_LIST -> "пустой массив ППИ" + BallisticsError.STEPPER_ERROR -> "ошибка при выходе на заданное время" + else -> "неизвестный код ошибки" + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/types/Types.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/types/Types.kt new file mode 100644 index 0000000..eb5c16d --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/types/Types.kt @@ -0,0 +1,341 @@ +package ballistics.types + +import ballistics.utils.math.Vector3D +import java.time.LocalDateTime +import kotlin.math.PI + +/** + * Тип метода интегрирования + */ +enum class IntegrationType { + RUNG4, + ADAMS7, +} + +/** + * Тип модели движения + */ +enum class ModDVType(val value: Int) { + FOTO(1), + + KONDOR(6), + + METEORM1(14), + METEORM2(15), + + BARS(16), + + KONDOR_PROGNOZ(19), + ; + + companion object { + val map = values().associate { it.value to it } + + fun fromInt( + value: Int, + defValue: ModDVType = FOTO, + ): ModDVType { + return map[value] ?: defValue + } + } +} + +/** + * Тип модели Земли + */ +enum class EarthType { + PZ90d0, + PZ90d02, +} + +enum class AstroType { + ATJ2000, +} + +/** + * Тип базовой СК + */ +enum class WorkCSType { + /** + * орбитальная + */ + WCSOrbit, + + /** + * путевая / трассовая + */ + WCSPath, +} + +/** + * Тип учета угла тангажа в расчете матрицы планирования + */ +enum class TangageType { + /** + * Конструктивный + */ + TTConstructive, + + /** + * Упреждающий + */ + TTProactive, +} + + +/** + * Параметры точки орбиты + */ +data class OrbitalPoint(val t: Double, var vit: Int, val r: Vector3D, val v: Vector3D) { + constructor() : this(0.0, 0, Vector3D(), Vector3D()) +} + +/** + * Параметры витка (ВУЗ) + */ +data class RevolutionParameter(var vuz: OrbitalPoint, var lVuz: Double, var hVuz: Double) { + constructor() : this(OrbitalPoint(), 0.0, 0.0) +} + +/** + * Начальные условия движения + */ +class InitialConditions(var point: OrbitalPoint, var sBall: Double, var f81: Double) { + constructor() : this(OrbitalPoint(), 0.0, 100.0) +} + +class TLE(val tle1: String, val tle2: String) { + constructor() : this("", "") +} + +/** + * затенение по азимуму + * азимут в диапазоне 0..2*PI + * при переходе через 0 долготы нужно заводить 2 записи! до 0 и после 0! + */ +data class ShadowAU( + val azimuthStart: Double, + val azimuthEnd: Double, + val elevation: Double, +) + +/** + * Параметры ППИ + */ +data class PPI( + val ppiNum: Int = 0, + val auNum: Int = 0, + val lat: Double = 0.0, + val long: Double = 0.0, + val height: Double = 0.0, + val elevMin: Double = 0.0, + val elevMax: Double = 0.0, + var shadowMin: List? = null, + var shadowMax: List? = null, +) { + constructor( + ppiNum: Int, + auNum: Int, + lat: Double, + long: Double, + height: Double, + elevMin: Double, + elevMax: Double, + ) : this (ppiNum, auNum, lat, long, height, elevMin, elevMax, null, null) +} + +/** + * Параметры ППИ(расширенные) + */ +internal class PPIExtParams() { + var nip = 0 + var au = 0 + var bn = 0.0 + var xn = 0.0 + var yn = 0.0 + var zn = 0.0 + var sl = 0.0 + var cl = 0.0 + var sb = 0.0 + var cb = 0.0 +} + +/** + * Параметры ППИ(расширенные) + */ +internal class PPIExt() { + var params = PPIExtParams() + var elevMin = 0.0 + var elevMax = 0.0 + var zoneIn = VisibilityParametersZRV() + var zoneMax = VisibilityParametersZRV() + var zoneOut = VisibilityParametersZRV() + var vit = 0 + var kaY = 0.0 + var t1 = 0.0 + var gam1 = 0.0 + var a1 = 0.0 + var vy1 = 0.0 + var t2 = 0.0 + var gam2 = 0.0 + var a2 = 0.0 + var vy2 = 0.0 + var shadowMin: Iterable? = null + var shadowMax: Iterable? = null + + fun isVisible( + azimuth: Double, + elevation: Double, + ): Boolean { + if (elevation > elevMax) { + return false + } + if (shadowMin != null) { + val a = if (azimuth >= 0) azimuth else azimuth + 2 * PI + val shad = shadowMin!!.find { it.azimuthStart <= a && a <= it.azimuthEnd } + if (shad != null && shad.elevation > elevation) { + return false + } + } + if (shadowMax != null) { + val a = if (azimuth >= 0) azimuth else azimuth + 2 * PI + val shad = shadowMax!!.find { it.azimuthStart <= a && a <= it.azimuthEnd } + if (shad != null && shad.elevation < elevation) { + return false + } + } + return true + } +} + +/** + * Параметры видимости КА-ППИ + */ +data class VisibilityParametersZRV(var t: Double, var range: Double, var azimuth: Double, var elevation: Double) { + constructor() : this(0.0, 0.0, 0.0, 0.0) +} + +/** + * Параметры зоны радиовидимости КА-ППИ + */ +data class ZRV( + val ppi: Int, + val au: Int, + val vit: Int, + val zoneIn: VisibilityParametersZRV, + val zoneMax: VisibilityParametersZRV, + val zoneOut: VisibilityParametersZRV, +) + +class Orientation( + var tang: Double, + var kren: Double, + var risk: Double, +) + +/** + * Параметры точки на Земной поверхности + */ +class BLHPoint( + var lat: Double, + var long: Double, + var h: Double, +) + +/** + * Параметры точки на границе полосы обзора / трассе полета + */ +class THBLPoint(val lat: Double, val long: Double, val range: Double, val sunAngle: Double) + +/** + * Параметры трассы полета и полосы обзора + */ +class FlightLine( + val t: Double, + val vit: Int, + val pv: Int, + val rightOuterSwath: THBLPoint, + val rightInternalSwath: THBLPoint, + val flightLine: THBLPoint, + val leftInternalSwath: THBLPoint, + val leftOuterSwath: THBLPoint, +) + +class FleghtLineSector( + val tStart: Double, + val tStop: Double, +) + +/** + * Объект оперативного каталога + */ +class OPKatObj( + val objON: Int, + val objN: Int, + val objUUID : String, + val pointNumb: Int, + val lat: Double, + val long: Double, + val height: Double, + val rollMin: Double, + val rollMax: Double, + val sunAngleMin: Double, +) + +/** + * Параметры видимости точки наблюдения + */ +class PointViewParams( + val objON: Int, + val objN: Int, + val objUUID : String, + val pointNumb: Int, + val vit: Int, + val traverz: Double, + val latTraverz: Double, + val longTraverz: Double, + val orientation: Orientation, + val range: Double, + val sunAngle: Double, + val sightAngle: Double, + val pv : Int, +) + +data class KeplerParams( + var dmv: Double = 0.0, + var ael: Double = 0.0, + var e: Double = 0.0, + var nakl: Double = 0.0, + var omegab: Double = 0.0, + var omegam: Double = 0.0, + var u: Double = 0.0, + var v: Double = 0.0, + var o: Double = 0.0, + var eA: Double = 0.0, + var tau: Double = 0.0, + var t: Double = 0.0, + var rP: Double = 0.0, + var rA: Double = 0.0, +) + + +class TLEParams( + var satName : String = "", + var noradId : Long = 0, + var revolution : Long = 0, + var time : LocalDateTime = LocalDateTime.now(), + var inclination : Double = 0.0, + var perigee : Double = 0.0, + var apogee: Double = 0.0, + var argPerigee: Double = 0.0, + var eccentricity: Double = 0.0, + var major: Double = 0.0, + var minor: Double = 0.0, + var meanAnomaly: Double = 0.0, + var period: Double = 0.0, + var semiMajor: Double = 0.0, + var semiMinor: Double = 0.0, + var ascendingNode: Double = 0.0, + var meanMotion: Double = 0.0, + var meanMotionTLE: Double = 0.0 +) \ No newline at end of file diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/DateTimeUtils.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/DateTimeUtils.kt new file mode 100644 index 0000000..91f5d3e --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/DateTimeUtils.kt @@ -0,0 +1,8 @@ +package ballistics.utils + +import java.time.LocalDateTime +import java.time.ZoneOffset + +fun toDateTime(t: Double) = LocalDateTime.ofEpochSecond(t.toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC) + +fun fromDateTime(t: LocalDateTime) = t.toEpochSecond(ZoneOffset.UTC) + t.nano / 1e9 diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/astro/AstronomerJ2000.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/astro/AstronomerJ2000.kt new file mode 100644 index 0000000..b636e0a --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/astro/AstronomerJ2000.kt @@ -0,0 +1,406 @@ +package ballistics.utils.astro + +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.utils.earth.getEarth +import ballistics.utils.math.Matrix3D +import ballistics.utils.math.Vector3D +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.PI +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.truncate + +class AstronomerJ2000(var earthType: EarthType) { + var ase = 149600000.0 + var earth = getEarth(earthType) + var baseDate = + LocalDateTime.of(2000, 1, 1, 12, 0, 0, 0).toEpochSecond(ZoneOffset.UTC) + .toDouble() / 86400 + + fun si2000(tDtTm: Double): Double { + var d = tDtTm / 86400.0 + var dat = truncate(d) + var tt = d - dat + if (tt < 0.125) { + dat-- + } + + var t = (dat - baseDate) / (36525.0) + var t2 = t * t + var t3 = t2 * t + + var lt = + 2.355555743494e0 + 8328.691425719e0 * t + + 1.545547e-4 * t2 + 2.50e-7 * t3 + var llt = + 6.240060126913e0 + 628.301955171e0 * t - 2.681989e-6 * t2 - + 6.593e-10 * t3 + var ft = + 1.6279050815375e0 + 8433.466156916e0 * t - 6.181956e-5 * t2 + + 5.0275e-9 * t3 + var dt = + 5.1984665886602e0 + 7771.377145594e0 * t - 5.559397e-4 * t2 + + 3.196e-8 * t3 + var omt = + 2.1824391966156e0 - 33.757044613e0 * t + 3.622625e-5 * t2 + + 3.734e-8 * t3 + + var psi = + (-17.1996e0 - 0.01742 * t) * sin(omt) + + (0.2062e0 + 0.00002 * t) * sin(2 * omt) + + (-1.3187e0 - 0.00016 * t) * sin(2 * (ft - dt + omt)) + + (0.1426e0 - 0.00034 * t) * sin(llt) + + (-0.0517 + 0.00012 * t) * sin(llt + 2 * ft - 2 * dt + 2 * omt) + + (0.0217 - 0.00005 * t) * sin(-llt + 2 * ft - 2 * dt + 2 * omt) + + (0.0129 + 0.00001 * t) * sin(2 * ft - 2 * dt + omt) + + var r = + (-0.2274e0 - 0.00002 * t) * sin(2 * ft + 2 * omt) + + (0.0712e0 + 0.00001 * t) * sin(lt) + + (-0.0386e0 - 0.00004 * t) * sin(2 * ft + omt) - + 0.0301e0 * sin(lt + 2 * ft + 2 * omt) - + 0.0158 * sin(lt - 2 * dt) + + 0.0123 * sin(-lt + 2 * ft + 2 * omt) + + psi += r + + if (tt < 0.125) { + tt += 0.875 + } else { + tt -= 0.125 + } + + r = 84381.448e0 - 46.84024e0 * t - 0.00059e0 * t2 + 0.001813e0 * t3 + r = r * PI / 180.0 / 3600.0 + psi = psi * PI / 180.0 / 3600.0 + r = cos(r) + r = psi * r + var s = + ( + 24110.54841 + 8640184.812866 * t + 0.093104 * t2 - + 6.2e-6 * t3 + ) * 2 * PI / 86400.0e0 + + s = s + r + 2 * PI * tt * 1.002737811906E0 + + var ds = s / (PI + PI) + var ts = truncate(ds) + + s = (ds - ts) * (PI + PI) + + return s + } + + fun grinvToASK(gsk: OrbitalPoint): OrbitalPoint { + var m = Matrix3D() + m.makeOzMatrix(si2000(gsk.t)) + + var ra = m * gsk.r + var va = m * gsk.v + + va.x -= earth.wEarth * ra.y + va.y += earth.wEarth * ra.x + + return OrbitalPoint(gsk.t, gsk.vit, ra, va) + } + + fun grinvToASK( + gsk: OrbitalPoint, + si: Double, + ): OrbitalPoint { + var m = Matrix3D() + m.makeOzMatrix(si) + var ra = m * gsk.r + var va = m * gsk.v + + va.x -= earth.wEarth * ra.y + va.y += earth.wEarth * ra.x + + return OrbitalPoint(gsk.t, gsk.vit, ra, va) + } + + fun grinvToASK( + gsk: Vector3D, + t: Double, + ): Vector3D { + var m = Matrix3D() + m.makeOzMatrix(si2000(t)) + return m * gsk + } + + fun askToGrinvich( + ask: Vector3D, + t: Double, + ): Vector3D { + var m = Matrix3D() + m.makeOzMatrix(-si2000(t)) + return m * ask + } + + fun askToGrinvich(ask: OrbitalPoint): OrbitalPoint { + var m = Matrix3D() + m.makeOzMatrix(-si2000(ask.t)) + + var ra = m * ask.r + var va = m * ask.v + + va.x += earth.wEarth * ra.y + va.y -= earth.wEarth * ra.x + + return OrbitalPoint(ask.t, ask.vit, ra, va) + } + + fun askToGrinvich( + ask: OrbitalPoint, + si: Double, + ): OrbitalPoint { + var m = Matrix3D() + m.makeOzMatrix(-si) + var ra = m * ask.r + var va = m * ask.v + + va.x += earth.wEarth * ra.y + va.y -= earth.wEarth * ra.x + + return OrbitalPoint(ask.t, ask.vit, ra, va) + } + + fun moonCoordinates(tm: Double): Vector3D { + var d: Double = tm / 86400.0 + d = d - 0.125 + val t: Double = (d - baseDate) / 36525.0 // Юлианский период ot 12h 1.1.2000 + val t2 = t * t + val t3 = t2 * t + // (* Средняя аномалия ЛУНЫ*) + val lL = 2.355555743494e0 + 8328.691425719e0 * t + 1.545547e-4 * t2 + 2.50e-7 * t3 + // (* Средняя аномалия СОЛНЦА*) + val lS = 6.240060126913e0 + 628.301955171e0 * t - 2.681989e-6 * t2 - 6.593e-10 * t3 + // (* Средний аргумент широты ЛУНЫ *) + val f = 1.6279050815375e0 + 8433.466156916e0 * t - 6.181956e-5 * t2 + 5.0275e-9 * t3 + // (* Разность средних долгот ЛУНЫ и СОЛНЦА *) + d = 5.1984665886602e0 + 7771.377145594e0 * t - 5.559397e-4 * t2 + 3.196e-8 * t3 + val d2 = d + d + // (* Вычисление горизонтального паралакса и радиуса ЛУНЫ *) + val pl1 = + 4.8481368110953599e-6 * ( + 3422.7 + 28.233 * cos(d2) + 3.086 * cos(lL + d2) + 186.539 * cos(lL) + 34.311 * + cos( + lL - d2, + ) + 0.6 * cos(lL - 4 * d) - 0.399 * cos(lS) + + 1.917 * cos(lS - d2) - 0.978 * cos(d) + 10.165 * cos(2 * lL) - + 0.949 * cos(lL + lS) + 1.443 * cos(lL + lS - d2) + 1.152 * cos(lL - lS) + 0.621 * cos(3 * lL) - 0.713 * + cos( + lL - 2 * f, + ) + ) + var rl = 6378.14 / pl1 + rl = rl * 1.0e3 + // (* Средняя долгота ЛУНЫ *) + val vL = + 3.8103405583329555 + 8399.7091123847003500 * t - + 0.0000281288897780 * t2 + 0.0000000921145994 * t3 + // (* Вычисление эклиптической долготы ЛУНЫ*) + val vSin = + 4.8481368110953599e-6 * ( + 2369.9 * sin(d2) + 191.95 * sin(lL + d2) + 22639.5 * sin(lL) - 4586.42 * sin(lL - d2) - 668.11 * + sin( + lS, + ) - 165.14 * sin(lS - d2) - 125.15 * sin(d) + 769.01 * sin(2 * lL) - 211.65 * sin(2 * lL - d2) - 109.66 * + sin( + lL + lS, + ) - 205.96 * sin(lL + lS - d2) + 147.69 * sin(lL - lS) - 411.6 * sin(2 * f) + ) + val arg = vL + vSin + val cl = cos(arg) + val sl = sin(arg) + // (* Вычисление эклиптической широты Луны *) + val betta = + 4.8481368110953599e-6 * ( + 117.26 * sin(f + d2) + 18461.48 * sin(f) - + 623.65 * sin(f - d2) + 1010.18 * sin(lL + f) - + 166.57 * sin(lL + f - d2) + 199.48 * sin(-lL + f + d2) - + 999.69 * sin(-lL + f) + ) + val cb = cos(betta) + val sb = sin(betta) + // (* Средний наклон эклиптики *) + val ep = 0.4090928042223290 - 0.0002270878917845 * t - 0.0000000028604007 * t2 + 0.0000000087896720 * t3 + val ce = cos(ep) + val se = sin(ep) + var moon = Vector3D() + moon.x = rl * cl * cb + moon.y = rl * (sl * cb * ce - sb * se) + moon.z = rl * (sb * ce + sl * cb * se) + + return moon + } + + fun sunCoordinates(tm: Double): Vector3D { + var d = tm / 86400.0 + var dd = d - 0.125 + var t = (dd - baseDate) / 36525.0 + var t2 = t * t + var t3 = t2 * t + + // Средняя долгота + var l = + 4.8481368110953599e-6 * ( + 1009677.850 + + (100 * 1296000.0 + 2771.270) * t + 1.089 * t2 + ) + + // Долгота перигелия + var p = + 4.8481368110953599e-6 * ( + 1018578.046 + 6190.046 * t + + 1.666 * t2 + 0.012 * t3 + ) + + // Эксцентриситет земной орбиты + var ek = 0.0167086342 - 0.00004203654 * t - 0.00000012673 * t2 + + // Средний наклон эклиптики + var e1 = + 4.8481368110953599e-6 * ( + 84381.448 - 46.84024 * t - + 0.00059 * t2 + 0.001813 * t3 + ) + + // Истинный наклон эклиптики + var oMT = + 2.1824391966156e0 - 33.757044613e0 * t + 3.622625e-5 * t2 + + 3.734e-8 * t3 + + var psi = 4.8481368110953599e-6 * 9.2025e0 * cos(oMT) + + e1 += psi + + // Средняя аномалия Земли + var g = l - p + var e = g + ek + + for (i in 1..5) + e = g + ek * sin(e) + + // Радиус Земли *) + var r = ase * (1.0 - ek * cos(e)) * 1.0e3 + + // Истинная аномалия + var sinv = ase / r * sqrt(1.0 - ek * ek) * sin(e) + var cosv = ase / r * (cos(e) - ek) + var v = atan2(sinv, cosv) + + // Эклиптическая истинная долгота *) + var la = v + p + + var sun = Vector3D() + // Собственно вычисления координат *) + sun.x = r * cos(la) + var y = r * sin(la) + sun.y = y * cos(e1) + sun.z = y * sin(e1) + + return sun + } + + fun sunCoordinatesGSK(t: Double): Vector3D { + var m = Matrix3D() + m.makeOzMatrix(-si2000(t)) + var sun = sunCoordinates(t) + return m * sun + } + + fun sunAngle( + t: Double, + currentObj: Vector3D, + ): Double { + var sun = sunCoordinatesGSK(t) + + var blh = earth.xyz2blh(currentObj) + + var m = + Matrix3D( + Vector3D(cos(blh.lat) * cos(blh.long), cos(blh.lat) * sin(blh.long), sin(blh.lat)), + Vector3D(-sin(blh.long), cos(blh.long), 0.0), + Vector3D(-sin(blh.lat) * cos(blh.long), -sin(blh.lat) * sin(blh.long), cos(blh.lat)), + ) + var pp = m * currentObj + var sp = m * sun + + var r = sp - pp + + return atan(r.x / sqrt(r.z * r.z + r.y * r.y)) + } + + fun sunMoonCorrection( + t3: Double, + x: Double, + y: Double, + z: Double, + ): Vector3D { + var corrections = Vector3D() + val lm = 4902.7854 + val mss = 132712518017.51 + val kapa = 0.0 + + var sun = sunCoordinates(t3) + sun = sun * (1 / 1000.0) + var moon = moonCoordinates(t3) + moon = moon * (1 / 1000.0) + + // Учет влияния Луны + var dpx: Double + var dpy: Double + var dpz: Double + + dpx = moon.x - x + dpy = moon.y - y + dpz = moon.z - z + + var r = dpx * dpx + dpy * dpy + dpz * dpz + var dr = sqrt(r) + var pp1 = r * dr + + r = moon.x * moon.x + moon.y * moon.y + moon.z * moon.z + dr = sqrt(r) + var pp2 = r * dr + + corrections.x = lm * (dpx / pp1 - moon.x / pp2) + corrections.y = lm * (dpy / pp1 - moon.y / pp2) + corrections.z = lm * (dpz / pp1 - moon.z / pp2) + + dpx = sun.x - x + dpy = sun.y - y + dpz = sun.z - z + + r = dpx * dpx + dpy * dpy + dpz * dpz + dr = sqrt(r) + pp1 = r * dr + + dr = sun.module() + r = dr * dr + pp2 = r * dr + + var kap = kapa + val rap = sqrt(x * x + y * y + z * z) + val cf = (sun.x * x + sun.y * y + sun.z * z) / (dr * rap) + if (cf <= 0) { + val rab1 = rap * sqrt(1 - cf * cf) + if (rab1 < 6378.388) kap = 0.0 + } + + val p1 = mss / pp1 * (1 - kap) + val p2 = mss / pp2 + // Закоментарить для неучета Солнца + corrections.x += (dpx * p1 - sun.x * p2) + corrections.y += (dpy * p1 - sun.y * p2) + corrections.z += (dpz * p1 - sun.z * p2) + + return corrections + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere2004.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere2004.kt new file mode 100644 index 0000000..4187059 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere2004.kt @@ -0,0 +1,237 @@ +package ballistics.utils.atmosphere + +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.truncate + +class Atmosphere2004() { + val aAtm = + listOf( + -2.53418e-02, + -2.44075e-03, + 3.08389e-06, + 2.90115e-06, + -4.99606e-08, + 3.36327e-10, + -1.0966e-12, + 1.73227e-15, + -1.06271e-18, + ) + + val fi1 = + listOf( + 0.5411, + 0.5515, + 0.5585, + 0.5585, + 0.5585, + 0.5585, + 0.5585, + ) + + val nAtm = + listOf( + -1.10059e+00, + -1.37626e-02, + -4.05631e-05, + 4.64077e-08, + 1.31904e+01, + -1.00640e-01, + 2.66200e-04, + -2.10328e-07, + -6.83628e-01, + 8.17955e-03, + -5.37817e-06, + 4.18766e+00, + -3.63633e-02, + 1.28280e-04, + -1.18060e-07, + -3.77859e+00, + 4.59058e-05, + -5.42317e-05, + 4.67279e-08, + 8.62648e+00, + -6.45632e-02, + 1.67737e-04, + -1.23719e-07, + -5.32838e-01, + 7.17357e-03, + -4.13071e-06, + 3.27388e+00, + -2.61311e-02, + 9.26510e-05, + -8.03080e-08, + -2.06474e+00, + -1.16758e-02, + -2.26591e-05, + 2.19816e-08, + 5.84701e+00, + -4.29259e-02, + 1.09960e-04, + -7.53447e-08, + -2.04545e-01, + 5.44465e-03, + -2.53356e-06, + 8.09259e-01, + -7.46853e-03, + 4.52020e-05, + -4.13791e-08, + 2.27394e+00, + -3.94166e-02, + 3.95835e-05, + -2.19992e-08, + 4.16132e+00, + -2.99269e-02, + 7.56965e-05, + -4.77865e-08, + 2.42053e-02, + 4.38092e-03, + -1.76272e-06, + 1.24462e+00, + -7.76442e-03, + 3.88966e-05, + -3.23585e-08, + 1.67658e+00, + -3.55099e-02, + 3.46586e-05, + -1.95730e-08, + 3.14269e+00, + -2.20308e-02, + 5.48269e-05, + -3.14805e-08, + 1.71702e-01, + 3.77273e-03, + -1.45257e-06, + 1.34930e-01, + 4.11962e-04, + 1.88965e-05, + -1.74602e-08, + -1.37615e+00, + -1.68609e-02, + 3.40709e-07, + 1.72566e-09, + 2.43907e+00, + -1.65847e-02, + 4.06547e-05, + -2.08670e-08, + 2.59209e-01, + 3.45977e-03, + -1.38895e-06, + 1.47530e-01, + 1.95702e-03, + 1.17537e-05, + -1.08184e-08, + 2.38462e+00, + -3.87222e-02, + 4.60938e-05, + -2.80992e-08, + 1.76084e+00, + -1.10685e-02, + 2.67151e-05, + -1.08944e-08, + 2.60394e-01, + 3.53130e-03, + -1.54729e-06, + -7.53476e-03, + 3.97475e-03, + 5.08266e-06, + -5.66222e-09, + ) + + private fun floorm(a: Double): Double { + return (if (a >= 0) floor(a) else -1 * (floor(a * -1))) + } + + fun atm2004Kav( + f81: Double, + deny: Double, + h: Double, + fld3: Array, + lxg: Double, + lyg: Double, + lzg: Double, + ): Double { + var roh : Double + + val ro0 = 1.58868e-08 + val n0A = 3.03853 + val n1A = 1.875e-03 + + val rg: Double = sqrt(fld3[3] * fld3[3] + fld3[4] * fld3[4] + fld3[5] * fld3[5]) + val xort: Double = fld3[3] / rg + val yort: Double = fld3[4] / rg + val zort: Double = fld3[5] / rg + + var j : Int + val f1: Double = floorm(f81 / 25.0 + 0.5) + var f0 = 25.0 * f1 + if (f0 >= 225.0) { + f0 = 250.0 + j = 7 + } else { + j = truncate(f1 - 2.0).toInt() + } + if (j < 1) j = 1 + + val fUnderscore = (f81 - f0) / f0 + val d = deny - 1.0 + val aD: Double = + aAtm[0] + d * ( + aAtm[1] + d * ( + aAtm[2] + d * ( + aAtm[3] + d * ( + aAtm[4] + d * ( + aAtm[5] + d * (aAtm[6] + d * (aAtm[7] + d * aAtm[8])) + ) + ) + ) + ) + ) + val h1 = h * 0.001 + val n = n0A + h1 * n1A + val x0ro: Double = lxg * cos(fi1[j - 1]) - lyg * sin(fi1[j - 1]) + val y0ro: Double = lxg * sin(fi1[j - 1]) + lyg * cos(fi1[j - 1]) + val cosfi = xort * x0ro + yort * y0ro + zort * lzg + val k10 = 0.5 * (1 + cosfi) + + val mk: Double = Math.pow(k10, 0.5 * n) + val nI = (15 * (j - 1)).toDouble() + + val aN: Double = + nAtm[((nI + 0).toInt())] + h1 * ( + nAtm[((nI + 1).toInt())] + h1 * ( + nAtm[((nI + 2).toInt()) ]+ h1 * + nAtm[( + (nI + 3).toInt() + )] + ) + ) + val cN: Double = + nAtm[((nI + 4).toInt())] + h1 * ( + nAtm[(nI + 5).toInt()] + h1 * ( + nAtm[(nI + 6).toInt()] + h1 * + nAtm[(nI + 7).toInt()] + ) + ) + val dN: Double = + nAtm[(nI + 8).toInt()] + h1 * (nAtm[(nI + 9).toInt()] + h1 * nAtm[(nI + 10).toInt()]) + val lN: Double = + nAtm[(nI + 11).toInt()] + h1 * ( + nAtm[(nI + 12).toInt()] + h1 * ( + nAtm[(nI + 13).toInt()] + h1 * + nAtm[(nI + 14).toInt()] + ) + ) + val k0 = 1.0 + lN * fUnderscore + val k1 = cN * mk + val k2 = dN * aD + roh = ro0 * exp(aN) + + roh *= k0 * (1.0 + k1 + k2) + + return roh + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere62.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere62.kt new file mode 100644 index 0000000..92e17ae --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere62.kt @@ -0,0 +1,16 @@ +package ballistics.utils.atmosphere + +internal fun getAtm62( + h: Double, + indAtm: Int, +): Double { + val ht = arrayOf(0.0, 0.2e+5, 0.6e+5, 1.0e+5, 1.5e+5, 3.0e+5, 6e+5, 9e+5, 10e6) + val a = arrayOf(0.125, 0.9091e-2, 0.263e-4, 0.4141e-7, 0.21727e-9, 0.48607e-11, 0.8904e-13, 0.6497e-14, 1e-17) + val k1 = arrayOf(-0.2639e-8, 0.4407e-9, -0.256e-8, 0.14688e-8, 0.8004e-10, 0.7111e-11, 0.1831e-11, 0.0, 0.0) + val k2 = arrayOf(0.7825e-4, 0.16375e-3, 0.5905e-4, 0.1787e-3, 0.37336e-4, 0.15467e-4, 0.9275e-5, 0.954e-5, 1e-6) + + val rab = k1[indAtm - 1] * ((h - ht[indAtm - 1]) * (h - ht[indAtm - 1])) - k2[indAtm - 1] * (h - ht[indAtm - 1]) + val ro = a[indAtm - 1] * Math.exp(rab) + + return ro +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere81.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere81.kt new file mode 100644 index 0000000..66ba2b9 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/atmosphere/Atmosphere81.kt @@ -0,0 +1,35 @@ +package ballistics.utils.atmosphere + +internal fun getAtm81(h: Double): Double { + val ht = arrayOf(0.0, 0.2e+5, 0.6e+5, 1.0e+5, 1.5e+5, 3.0e+5, 6.0e+5, 9.0e+5) + val a = arrayOf(0.12522, 0.91907e-2, 0.31655e-4, 0.54733e-7, 0.20474e-9, 0.19019e-11, 0.11495e-13, 0.58038e-15) + val k1 = arrayOf(-0.20452e-8, 0.62669e-9, -0.86999e-9, 0.12870e-8, 0.10167e-9, 0.97266e-11, 0.15127e-10, 0.0) + val k2 = arrayOf(0.90764e-4, 0.16739e-3, 0.12378e-3, 0.17527e-3, 0.45825e-4, 0.19885e-4, 0.14474e-4, 0.39247e-5) + + var i: Int + var rab : Double + var ro : Double + if (h < 9.0e+5) { + i = 0 + i += 1 + rab = ht[0] - h + while (rab < 0.0) { + i += 1 + rab = ht[i - 1] - h + } + if (rab == 0.0) { + ro = a[i - 1] + } else { + i -= 1 + rab = k1[i - 1] * (h - ht[i - 1]) * (h - ht[i - 1]) - k2[i - 1] * (h - ht[i - 1]) + ro = a[i - 1] * Math.exp(rab) + } + } else { + i = 9 + i -= 1 + rab = k1[i - 1] * (h - ht[i - 1]) * (h - ht[i - 1]) - k2[i - 1] * (h - ht[i - 1]) + ro = a[i - 1] * Math.exp(rab) + } + + return ro +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/AbstractEarth.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/AbstractEarth.kt new file mode 100644 index 0000000..cb807dd --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/AbstractEarth.kt @@ -0,0 +1,27 @@ +package ballistics.utils.earth + +import ballistics.types.BLHPoint +import ballistics.utils.math.Vector3D + +abstract class AbstractEarth { + var fM = 398600.44e9 + var ekvRadius = 6378136.0 + var wEarth = 0.7292115e-4 + var alphaEllips = 1 / 298.257839303 + var middleRadius = 6378100.0 + var polarRadius = (1 - alphaEllips) * ekvRadius + var c20 = -1.08262741e-3 + var eElips2 = 2 * alphaEllips - alphaEllips * alphaEllips + + fun blh2xyz(blh: BLHPoint): Vector3D { + return blh2xyz(blh.lat, blh.long, blh.h) + } + + abstract fun blh2xyz( + b: Double, + l: Double, + h: Double, + ): Vector3D + + abstract fun xyz2blh(r: Vector3D): BLHPoint +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthFactory.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthFactory.kt new file mode 100644 index 0000000..ac619e3 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthFactory.kt @@ -0,0 +1,10 @@ +package ballistics.utils.earth + +import ballistics.types.EarthType + +fun getEarth(earth: EarthType): AbstractEarth { + return when (earth) { + EarthType.PZ90d0 -> EarthPZ90d0() + EarthType.PZ90d02 -> EarthPZ90d02() + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthPZ90d0.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthPZ90d0.kt new file mode 100644 index 0000000..99e5dc3 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthPZ90d0.kt @@ -0,0 +1,57 @@ +package ballistics.utils.earth + +import ballistics.types.BLHPoint +import ballistics.utils.math.Vector3D +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.hypot +import kotlin.math.sqrt + +open class EarthPZ90d0 : AbstractEarth() { + init { + fM = 398600.44e9 + ekvRadius = 6378136.0 + wEarth = 0.7292115e-4 + alphaEllips = 1 / 298.257839303 + middleRadius = 6378100.0 + polarRadius = (1 - alphaEllips) * ekvRadius + c20 = -1.08262741e-3 + eElips2 = 2 * alphaEllips - alphaEllips * alphaEllips + } + + override fun blh2xyz( + b: Double, + l: Double, + h: Double, + ): Vector3D { + var v = Vector3D() + val sb = Math.sin(b) + val cb = Math.cos(b) + val sl = Math.sin(l) + val cl = Math.cos(l) + val n = ekvRadius / Math.sqrt(1 - eElips2 * sb * sb) + v.x = (n + h) * cb * cl + v.y = (n + h) * cb * sl + v.z = ((1 - eElips2) * n + h) * sb + return v + } + + override fun xyz2blh(r: Vector3D): BLHPoint { + var l = atan2(r.y, r.x) + if (l < 0.0) l = PI * 2.0 + l + + var r1: Double = hypot(r.x, r.y) + val rr: Double = hypot(r1, r.z) + val sinFiC: Double = r.z / rr + val cosFiC = r1 / rr + val fic: Double = atan2(r.z, r1) + val ekvadrat: Double = (2.0 - alphaEllips) * alphaEllips + r1 = 1.0 - ekvadrat * cosFiC * cosFiC + val delta = ekvadrat * sinFiC * cosFiC / r1 + val rE: Double = ekvRadius * sqrt(1.0 - ekvadrat) / sqrt(r1) + val eMal = rE * delta / rr + var b = fic + eMal + var h = ((rr - rE) * (1.0 - eMal * delta / 2.0)) + return BLHPoint(b, l, h) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthPZ90d02.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthPZ90d02.kt new file mode 100644 index 0000000..57294d7 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/earth/EarthPZ90d02.kt @@ -0,0 +1,14 @@ +package ballistics.utils.earth + +class EarthPZ90d02 : EarthPZ90d0() { + init { + fM = 398600.4418e9 + ekvRadius = 6378136.0 + wEarth = 0.7292115e-4 + alphaEllips = 1 / 298.25784 + middleRadius = 6378100.0 + polarRadius = (1 - alphaEllips) * ekvRadius + c20 = -1.08262575e-3 + eElips2 = 2 * alphaEllips - alphaEllips * alphaEllips + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/geopotential/Geopotencial1990.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/geopotential/Geopotencial1990.kt new file mode 100644 index 0000000..9a0af5b --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/geopotential/Geopotencial1990.kt @@ -0,0 +1,1669 @@ +package ballistics.utils.geopotential + +import ballistics.types.BallisticsError +import ballistics.utils.earth.EarthPZ90d0 +import ballistics.utils.math.Vector3D +import kotlin.math.round +import kotlin.math.truncate + +class Geopotencial1990() { + protected var earth = EarthPZ90d0() + + // Тессеральные гармоники + // bnm:array[1..703] of extended=( + val bnm = + arrayOf( + 0.00000000e+00, + 8.06804611e+02, + -5.64634119e+07, + 0.00000000e+00, + 1.68100518e+07, + -1.32193915e+07, + 1.22067285e+07, + 0.00000000e+00, + -2.79808712e+07, + 9.10647011e+06, + -7.38253910e+05, + 3.94495851e+05, + 0.00000000e+00, + -4.52542827e+06, + -3.29994083e+06, + -4.15145712e+05, + 2.64614123e+04, + -1.03039147e+05, + 0.00000000e+00, + 1.58378692e+06, + -2.83608311e+06, + 3.42080806e+03, + -1.10044041e+05, + -2.69845197e+04, + -3.42015042e+03, + 0.00000000e+00, + 3.96396039e+06, + 6.02980151e+05, + -2.15610894e+05, + -1.61653588e+04, + 3.39515442e+02, + 6.53525338e+02, + 3.78167688e+01, + 0.00000000e+00, + 2.51316808e+06, + 2.70507179e+05, + -5.72179952e+04, + 5.40144241e+03, + 9.14192759e+02, + 5.40758957e+02, + 2.34983175e+01, + 1.00929004e+01, + 0.00000000e+00, + 7.70745052e+05, + -1.18047919e+05, + -4.21637386e+04, + 9.44980453e+02, + -4.03847024e+02, + 1.79201415e+02, + -1.16285158e+01, + -1.45030682e-02, + 5.08572145e-01, + 0.00000000e+00, + -4.79963795e+06, + -1.26451024e+05, + -5.55884353e+04, + -2.77267319e+03, + -1.95076401e+02, + -3.51661346e+01, + 5.78632343e-03, + -6.23707308e-01, + -4.86674940e-02, + -6.11501087e-03, + 0.00000000e+00, + -1.31926929e+06, + -3.36671510e+05, + -4.37371496e+04, + -1.70199547e+03, + 1.36704904e+02, + 8.50111440e+00, + -2.26554284e+00, + 5.65296386e-02, + 1.48687459e-02, + -1.65564179e-03, + -6.35927481e-04, + 0.00000000e+00, + -1.26132278e+06, + 6.60590556e+04, + 6.74387020e+03, + 2.54126971e+01, + 2.18244774e+01, + 6.99021649e+00, + 4.58715883e-01, + 2.35811893e-02, + 3.60420621e-03, + 6.15513785e-04, + -1.59407879e-05, + -6.39559595e-06, + 0.00000000e+00, + 1.41271091e+06, + -1.59645859e+05, + 1.46080497e+04, + -1.60914346e+02, + 7.22830828e+01, + -2.56129381e-01, + -5.64083036e-02, + -3.13902227e-03, + 3.15131121e-03, + -2.31438060e-04, + 2.02008257e-06, + 1.04175113e-05, + 1.56738413e-06, + 0.00000000e+00, + 9.39650180e+05, + 1.91291633e+03, + 2.43467303e+03, + -1.67812408e+02, + -1.47227115e+01, + 4.74238595e-01, + -2.40140283e-02, + -5.61104438e-03, + 1.06845639e-03, + 2.75292094e-06, + -1.08636774e-05, + -1.09600174e-06, + 2.05977857e-07, + -3.59437702e-09, + 0.00000000e+00, + 3.38285848e+05, + -6.41362876e+04, + 1.42404424e+03, + 3.27142121e+01, + 6.80307846e+00, + -1.39136767e+00, + 3.06513874e-03, + 5.66868034e-03, + 6.88961725e-04, + 2.68401684e-05, + 2.43806843e-06, + 1.81006046e-07, + -5.68409025e-09, + -4.24315073e-09, + -3.43232291e-10, + 0.00000000e+00, + 1.11501311e+06, + 6.64524201e+04, + -2.83615455e+03, + 3.66742029e+02, + 6.59688946e-01, + -8.97458181e-01, + -2.14184807e-02, + 7.37731369e-04, + -3.06037506e-04, + 8.77144676e-06, + -8.68765977e-08, + 3.05858784e-08, + 2.00753629e-10, + -1.72288178e-09, + -1.94450860e-10, + 7.76961924e-12, + 0.00000000e+00, + -5.07240779e+05, + 1.62347456e+04, + 8.51203358e+02, + 1.14294783e+02, + 2.57023696e+00, + -8.08067455e-01, + -9.82522953e-03, + -3.99872638e-05, + -1.49209936e-04, + 6.52047705e-06, + 1.85232161e-07, + 3.12824129e-08, + 3.15657412e-09, + 1.73301502e-10, + 9.03836111e-12, + 9.79465749e-13, + -5.92788357e-13, + 0.00000000e+00, + -1.12559499e+06, + 1.08308887e+04, + -3.98215989e+02, + -2.63242212e+01, + 7.53811985e+00, + -1.76261522e-01, + -2.00079621e-03, + 2.95773901e-05, + 8.31734623e-05, + -1.39587950e-06, + -9.88317411e-09, + -1.38437918e-08, + -2.23992148e-09, + -5.82227430e-11, + -8.03493412e-12, + 2.12389864e-13, + 2.76595571e-14, + -8.45300034e-15, + 0.00000000e+00, + 3.89882237e+05, + -7.31067228e+03, + -3.84050173e+01, + -5.22090188e+00, + 3.27302338e+00, + 1.78378701e-01, + -4.85426846e-03, + -1.90481420e-04, + 7.31195677e-06, + -1.48156016e-06, + 6.73004259e-08, + -8.68543809e-10, + -7.85597711e-10, + -2.70840132e-11, + -2.24405839e-12, + -1.36457673e-13, + -2.03102683e-14, + -1.89387483e-15, + 3.55012584e-16, + 0.00000000e+00, + -1.57950975e+05, + 2.16101663e+04, + 1.94228206e+03, + -5.08062759e+01, + -4.09144988e-01, + 4.94233337e-02, + 5.43504828e-04, + 1.19557329e-04, + 6.85384425e-06, + -4.92464976e-07, + -6.50380709e-08, + 4.16912510e-09, + 5.75354866e-11, + -1.10823641e-11, + -2.12838730e-13, + 5.00013424e-16, + -4.21470531e-15, + 3.84944718e-17, + 3.38389008e-17, + -3.59617059e-18, + 0.00000000e+00, + 1.19528089e+06, + 5.97154797e+03, + 1.87979209e+03, + 3.81044052e+01, + 2.08569404e-01, + -5.18252948e-02, + 6.50488233e-03, + 8.15154316e-06, + 1.01868385e-05, + -3.43225172e-08, + -6.74724431e-08, + 1.78662425e-09, + 7.48399455e-11, + 3.58208124e-12, + 2.69250064e-13, + -1.18932411e-14, + -2.48295364e-17, + -1.08641680e-16, + 1.18775083e-17, + 1.00002141e-18, + -1.31268885e-19, + 0.00000000e+00, + -3.14961814e+05, + -4.24994497e+03, + 7.32426316e+02, + 4.80245877e+01, + 9.74029234e-01, + -1.02648100e-02, + 2.77938915e-03, + 7.50230684e-05, + 8.18787993e-06, + 4.79607747e-07, + -2.22834381e-08, + -4.90346725e-10, + 6.30308084e-11, + 1.93628646e-12, + 2.46190795e-14, + -3.77025351e-15, + -4.81569859e-16, + -2.80391482e-17, + -2.41044727e-19, + 5.29691306e-19, + 3.94616469e-20, + 1.07272302e-21, + 0.00000000e+00, + 5.26100303e+05, + -1.14034378e+04, + -3.19073269e+02, + 1.06322001e+01, + -3.60869215e-01, + 2.19052206e-02, + 9.39348537e-04, + 2.78894353e-05, + -1.15439444e-06, + -1.76860859e-08, + 1.60221743e-08, + -5.49822268e-10, + -1.05598091e-11, + -3.30567604e-13, + -3.88356712e-15, + 2.43657153e-15, + -1.17354363e-16, + -6.85107025e-18, + 6.36693586e-19, + -2.45152449e-20, + 8.45627904e-21, + 2.22704009e-22, + -1.09534735e-22, + 0.00000000e+00, + -3.98302577e+05, + 2.13900074e+04, + -7.47966798e+02, + 1.43130710e+01, + -1.17992789e+00, + 4.13367585e-03, + -9.09703429e-04, + 1.41779709e-05, + -2.22558265e-06, + 2.78110033e-07, + 1.15887923e-08, + -1.36309561e-10, + 2.74983709e-12, + -5.25627983e-14, + -2.99823759e-14, + -1.19650364e-16, + -1.95160069e-17, + 5.09309775e-19, + -2.76826737e-19, + 4.61014581e-21, + 2.30003693e-21, + -8.04399089e-23, + 7.29918742e-24, + -1.48901298e-24, + 0.00000000e+00, + 7.00605985e+04, + 8.46095036e+03, + -2.41050269e+02, + 2.70063966e+00, + -9.48416957e-01, + 3.88419030e-03, + -8.87061039e-04, + -7.02885776e-06, + 1.71888654e-06, + 9.86943673e-09, + 2.34967075e-09, + 1.71974851e-10, + -7.84623820e-12, + 2.85072382e-13, + -8.70524145e-15, + -8.82193109e-16, + -2.20789505e-17, + -1.22505418e-18, + 3.58368445e-20, + -2.11142301e-21, + 2.95536484e-22, + 5.98897013e-24, + -1.95325548e-24, + -2.84062969e-26, + 1.63223687e-26, + 0.00000000e+00, + -2.73928662e+05, + 7.01249717e+03, + 4.69686961e+01, + -1.21191254e+01, + 1.45559281e-01, + 7.47371813e-03, + -1.59926592e-04, + -1.74384599e-05, + 1.96969424e-07, + -5.54928457e-08, + 8.68665158e-10, + -3.91256688e-11, + 1.59976825e-13, + 4.05336978e-14, + 6.81760834e-16, + -2.08948955e-16, + 4.54148205e-18, + 5.52319410e-19, + 4.05114697e-20, + -3.23746790e-21, + 9.99284273e-23, + 1.24891702e-23, + 6.37750284e-25, + 1.02474539e-25, + 9.91917657e-28, + -1.07464244e-29, + 0.00000000e+00, + 1.12517853e+05, + -3.63754005e+03, + 2.42190154e+02, + 1.10640369e+01, + 3.47423032e-01, + 3.59317100e-03, + 7.45540814e-04, + -2.12071845e-05, + 5.47750414e-07, + -1.49940407e-09, + -1.80650484e-09, + -4.18269230e-11, + -8.82712320e-13, + 8.62965326e-14, + -1.83700754e-16, + -9.92339234e-17, + 1.03384624e-18, + 2.76170539e-19, + 6.22763113e-21, + 2.11365357e-22, + 3.69388966e-24, + 1.06551514e-24, + -1.42695479e-25, + 7.81206899e-27, + 4.03559789e-28, + 3.57887422e-29, + 4.93844146e-30, + 0.00000000e+00, + -2.66945900e+04, + -1.02269712e+04, + 7.27414742e+01, + 8.36090970e+00, + 2.29008417e-01, + 1.01032041e-02, + 8.31880234e-04, + 3.42994537e-06, + -4.34091619e-07, + 2.24118614e-08, + -5.08409853e-10, + 3.09175288e-11, + 1.45119061e-14, + -5.04583124e-14, + 1.19964091e-16, + -8.41067438e-17, + -4.35558797e-19, + -1.66726998e-19, + 1.91591741e-20, + 3.46814402e-22, + 2.42008044e-24, + -2.96703781e-25, + -1.81939175e-26, + -2.00553132e-27, + -4.38219651e-28, + 4.91012352e-30, + -6.85389053e-31, + -9.35909039e-32, + 0.00000000e+00, + 5.77695646e+04, + -4.19507567e+03, + -4.11680286e+02, + 3.26638597e-01, + 3.00630236e-01, + 5.29374245e-03, + -1.70909497e-04, + 1.31578975e-05, + -1.13436588e-07, + 1.13395517e-08, + -1.03433551e-10, + -1.32817729e-11, + -7.73753385e-13, + -1.08647546e-14, + -7.16734091e-16, + -6.01964828e-17, + -1.20577183e-18, + -3.40730052e-20, + 5.21929362e-21, + 1.85542828e-22, + -3.55647056e-24, + 1.29635086e-25, + -2.25138633e-27, + -4.26525250e-28, + 2.81033204e-29, + -1.70805309e-30, + 2.05229827e-32, + -9.30726947e-33, + -3.69288045e-33, + 0.00000000e+00, + -1.64557189e+05, + 1.11455038e+02, + -2.81350565e+02, + -1.82330598e+00, + -1.65279469e-01, + 1.83643848e-03, + -3.16510672e-04, + 3.26850763e-06, + -4.42194462e-08, + -1.32427368e-08, + 2.78889120e-10, + -1.31437764e-11, + 1.31881592e-13, + 1.13676879e-14, + -2.09298343e-16, + 1.85733373e-17, + -7.03713160e-19, + -2.58127632e-20, + 1.36687506e-21, + 8.64831330e-23, + -2.17352130e-24, + -1.13126399e-25, + -5.31402135e-27, + -1.45335760e-28, + -2.36406478e-29, + 1.74000015e-30, + 1.09606992e-31, + -1.68732597e-33, + 2.76664928e-34, + 1.13886697e-34, + 0.00000000e+00, + -3.74627295e+05, + 5.65591149e+03, + -3.52577111e+02, + -8.36778423e+00, + -2.84103326e-02, + -2.04048890e-03, + -2.34365457e-04, + 2.11650228e-06, + 7.73297286e-08, + -4.15262078e-09, + 5.17852503e-10, + 7.10724795e-12, + 1.77643699e-13, + 3.19346032e-15, + -8.82438357e-17, + 1.64997356e-17, + 6.29017648e-19, + -6.30700705e-21, + -3.07245656e-22, + 2.85403281e-23, + -1.16059635e-25, + -8.22121060e-26, + 1.45709178e-27, + -3.35440434e-30, + -3.17668149e-31, + 3.14157854e-31, + 2.50605676e-32, + 7.87928569e-35, + -4.95931484e-35, + 3.74162733e-37, + -4.45175332e-37, + 0.00000000e+00, + -1.95152318e+05, + 5.40322936e+02, + 1.47988210e+02, + -1.22566588e+00, + -3.38707915e-02, + -9.39798766e-03, + -8.44917760e-06, + 2.27419170e-06, + 1.62428519e-08, + -2.77872996e-09, + 1.08578936e-10, + 8.95722907e-12, + 1.01569794e-13, + 3.45102156e-15, + -9.16949968e-17, + 3.42273129e-19, + 5.97656473e-19, + -5.29598720e-21, + -1.72463286e-22, + 4.51474575e-25, + 1.42528508e-25, + -1.54534520e-26, + -3.75847300e-28, + 2.77952974e-29, + 1.25389072e-30, + -5.86130028e-33, + -1.42505934e-33, + -1.17099570e-34, + -1.25677149e-35, + -2.15557792e-37, + 3.84068289e-38, + 1.66029525e-39, + 0.00000000e+00, + 1.48373627e+05, + -3.79461996e+03, + 9.54301939e+01, + 5.62430808e+00, + 2.02087725e-01, + -4.20734679e-03, + -1.28668835e-05, + 2.16749170e-06, + -2.66040761e-08, + -1.71308478e-09, + -4.12059366e-11, + 2.88398012e-12, + 1.24886935e-13, + 2.14335284e-15, + -1.27160861e-16, + -8.24260509e-19, + 2.43441951e-19, + 3.12191821e-22, + -1.32712117e-22, + -8.49546708e-24, + -1.78939285e-25, + -1.13392977e-26, + -4.76512678e-28, + -4.84902171e-30, + -7.33350871e-31, + 1.45662024e-32, + 6.14997098e-34, + -4.10450751e-35, + 1.70575867e-37, + -5.16966878e-37, + 1.18905865e-38, + -1.07058263e-39, + 3.58130484e-40, + 0.00000000e+00, + 3.83073911e+04, + 2.10550476e+03, + 1.22021093e+02, + 2.23244727e+00, + 9.03333045e-02, + -1.35979691e-03, + -5.80142360e-05, + 3.50061188e-07, + 1.61158647e-08, + -6.73892011e-11, + 1.58650762e-11, + 7.23333329e-13, + 5.93320794e-14, + -2.14503998e-16, + 3.00038001e-17, + -3.45255399e-18, + -4.74704273e-20, + 2.50520889e-22, + -2.32407319e-23, + -3.82582014e-24, + -9.23488905e-26, + 1.92826909e-27, + -3.79014232e-28, + -4.01358024e-31, + -2.08047750e-31, + -1.21850610e-32, + 1.46310826e-36, + -5.78355959e-35, + -1.03312230e-36, + -3.33192067e-39, + 3.76329719e-39, + -2.71450899e-40, + 1.37623336e-41, + -3.12341290e-42, + 0.00000000e+00, + 1.82734708e+05, + 7.33473229e+03, + -9.52113377e+01, + 6.05224478e-01, + -8.39219751e-02, + -6.70009831e-04, + 3.58729616e-05, + 3.75281325e-06, + -1.18878418e-07, + 1.94957299e-09, + -1.51829924e-11, + -2.85131866e-12, + 7.18546744e-14, + -1.34892472e-15, + 4.13622969e-17, + -3.21169046e-19, + -1.16508869e-19, + -1.71850298e-22, + 1.98516670e-24, + -1.53437903e-24, + 1.24518801e-26, + -2.91915073e-28, + -7.33533509e-29, + 3.55585666e-31, + 4.61890165e-32, + 3.40654876e-33, + -4.40629035e-34, + -2.61933162e-35, + -3.11342891e-37, + 1.42031168e-39, + 5.73882221e-40, + -2.13962326e-41, + -2.52793557e-42, + -3.41574876e-44, + -5.38222840e-44, + 0.00000000e+00, + 6.41401549e+03, + 4.36850888e+02, + -2.13915018e+02, + -2.35426915e+00, + -2.97160149e-02, + 5.90586983e-04, + 4.12909551e-05, + 5.98421262e-07, + 5.04317367e-10, + 8.40208178e-10, + 1.21054190e-11, + -2.45123807e-12, + 6.89196786e-15, + -2.97591235e-16, + 6.63072922e-18, + 1.19737258e-18, + -1.02315003e-20, + 1.00354027e-21, + 2.85772464e-23, + -4.20997213e-25, + -7.20622344e-27, + 1.00343191e-27, + -2.51480995e-29, + -2.19223370e-31, + 6.54245137e-32, + 2.73344890e-33, + 7.53945325e-35, + 1.60902956e-36, + -8.94141138e-38, + -1.21584333e-39, + 1.04388725e-40, + 4.46324452e-42, + -1.73919253e-43, + 3.20041838e-44, + -9.46556562e-45, + -8.78140258e-47, + 0.0, + 0.0, + 0.0, + ) + + // Секториальные гармоники + // anm:array[1..703] of extended=( + val anm = + arrayOf( + 0.00000000e+00, + 4.03402312e+03, + 9.83801418e+07, + 1.58262324e+08, + 1.37220330e+08, + 1.93578733e+07, + 6.23948457e+06, + 1.01901549e+08, + -3.15019732e+07, + 4.89155217e+06, + 3.69474284e+06, + -2.55175808e+05, + 1.42437218e+07, + -3.60492953e+06, + 6.59361117e+06, + -9.32008211e+05, + -1.42666818e+05, + 2.94782238e+04, + -3.23233386e+07, + -3.81859324e+06, + 2.95588926e+05, + 7.34048378e+04, + -2.15020688e+04, + -1.35530381e+04, + 2.17963614e+02, + 2.18781215e+07, + 1.28108280e+07, + 1.99872959e+06, + 2.13190080e+05, + -3.60187152e+04, + 3.89281547e+01, + -1.55191666e+03, + 1.64854215e+01, + 1.25821593e+07, + 1.01136544e+06, + 3.57972869e+05, + -1.22700249e+04, + -1.98226908e+04, + -2.45345704e+02, + -1.15876923e+02, + 2.18508240e+01, + -9.05644458e+00, + 7.79633402e+06, + 5.63521054e+06, + 9.38496126e+04, + -8.21264756e+04, + -3.77029548e+02, + -1.49381199e+02, + 5.72031815e+01, + -1.43930128e+01, + 3.62454144e+00, + -1.91864055e-01, + 1.39814241e+07, + 2.76841314e+06, + -3.22128708e+05, + -9.29145937e+02, + -3.07154613e+03, + -1.63999189e+02, + -1.55161301e+01, + 6.04933804e-01, + 3.22269056e-01, + 1.47500485e-01, + 2.52520100e-02, + -1.50906306e+07, + 3.25021320e+05, + 5.70772244e+04, + -1.17839227e+04, + -1.04993228e+03, + 1.23178626e+02, + 6.17949519e-01, + 3.17643116e-01, + -1.67594447e-02, + -1.45228710e-02, + -2.55047112e-03, + 5.37946607e-04, + 1.10240855e+07, + -1.84404330e+06, + 3.29867632e+04, + 1.40836779e+04, + -1.30788088e+03, + 6.65380394e+01, + 1.84647225e+00, + -2.05971069e-01, + -2.61350087e-02, + 6.50725810e-03, + -4.28734599e-05, + 2.70993390e-05, + -1.44181416e-06, + 1.42038031e+07, + -1.81609937e+06, + 1.26204460e+05, + -5.08546802e+03, + -1.26149804e+02, + 7.27556017e+01, + -2.43790304e+00, + 2.67820936e-02, + -4.64519006e-03, + 1.28508542e-03, + 2.53756297e-04, + -3.44980634e-05, + -3.70338207e-06, + -1.37094657e-06, + -7.55206730e+06, + -4.64734374e+05, + -7.78830189e+04, + 7.98164544e+03, + 4.23780128e+01, + 2.50310747e+01, + -1.38595315e+00, + 1.39498814e-01, + -1.34124148e-02, + 7.92830330e-04, + 9.93123558e-05, + 4.96117773e-06, + 2.92267117e-07, + 1.44494647e-07, + -4.54598189e-08, + 1.80937330e+05, + 5.72068388e+05, + -3.95524258e+04, + 6.23187584e+03, + -3.26785567e+02, + 6.09392358e+00, + 1.45938088e+00, + 1.52402238e-01, + -7.94571277e-03, + 1.68468735e-04, + 9.08815474e-06, + -1.22443606e-07, + -3.95856843e-07, + -3.59572274e-08, + 1.46954670e-09, + -3.80697790e-10, + -1.09496645e+06, + 6.88956176e+05, + -3.18304680e+04, + -2.94643031e+03, + 2.87594712e+02, + -2.17203704e+00, + 8.01971590e-02, + -1.57880448e-02, + -3.75595872e-03, + -1.94293560e-04, + -1.17020463e-05, + 9.99347387e-07, + 9.15774553e-08, + 5.42453060e-09, + -8.40720496e-10, + -6.23160974e-11, + -3.94419543e-11, + 7.22071627e+06, + -7.26636572e+05, + -3.28295030e+04, + 2.47442840e+02, + 2.07808702e+01, + -1.93535779e+00, + -1.09098350e-01, + 3.73915115e-02, + 2.64875636e-03, + 1.14495356e-05, + 2.59680748e-07, + -5.63065104e-07, + 5.26638675e-08, + 2.41422083e-09, + -1.83470784e-10, + 7.16437861e-12, + -5.40125658e-12, + -1.18770683e-12, + 4.67193453e+06, + -5.43611200e+04, + 8.33994177e+03, + 4.11977234e+02, + 2.20157597e+02, + 1.93927836e+00, + 1.51316033e-01, + 4.01884067e-03, + 1.63185598e-03, + -6.15731886e-05, + 2.18961487e-06, + -1.75971842e-07, + -2.46318512e-08, + -4.66298534e-10, + -4.67219533e-11, + -1.55336101e-11, + 5.85842057e-13, + 2.63902848e-14, + 3.42879777e-15, + -1.29182684e+06, + -2.42933149e+05, + 4.82475248e+04, + -8.50934723e+02, + 1.04221761e+02, + 3.40129611e+00, + 4.09176188e-02, + 6.91748303e-04, + 8.42128391e-04, + 6.05521448e-06, + -3.13755308e-06, + 9.79905116e-08, + -3.75072653e-09, + -2.90448844e-10, + -1.07720505e-11, + -2.55721943e-12, + -1.67579601e-13, + 4.65459639e-14, + 4.82915773e-15, + -4.92335578e-17, + 9.72393852e+06, + 2.47143565e+05, + 2.69721906e+04, + -4.38108726e+01, + 1.01546830e+01, + -3.71352475e-01, + 5.79101694e-02, + -9.22246348e-03, + -8.56864882e-05, + 1.65333964e-05, + -1.62103047e-06, + 6.57147677e-08, + -1.24497786e-09, + 3.75480418e-10, + 9.93347455e-12, + -1.49292036e-12, + -4.04556301e-14, + 2.79859411e-15, + 5.39272578e-16, + -1.85440348e-17, + 1.50362528e-19, + 3.30713367e+06, + -3.84765583e+05, + 2.56462267e+03, + 1.12799299e+03, + 6.99367072e+00, + 3.76216960e-01, + 1.29563237e-02, + -3.24469343e-03, + -1.23657371e-04, + 1.34578412e-05, + -2.26124816e-07, + 2.78713908e-08, + 6.93087021e-10, + -1.16628401e-10, + 7.68629676e-12, + 3.87301949e-13, + 1.61542723e-14, + -8.23099135e-16, + 2.09033360e-16, + -2.28204218e-17, + -2.31467885e-18, + 1.00500328e-19, + -5.79373072e+06, + 4.15117043e+05, + -1.80563950e+04, + 5.63364460e+02, + -1.02757966e+01, + -1.18619404e+00, + -9.57721499e-03, + 2.97775410e-03, + -2.69137195e-04, + 7.37786278e-06, + 3.28592025e-07, + -2.64401304e-09, + 6.38896823e-10, + -5.81822842e-11, + 1.96166119e-12, + 3.11425684e-13, + 3.18593373e-15, + 5.53873555e-16, + 3.51454010e-17, + 2.87747153e-18, + -2.58582411e-19, + -6.87201917e-20, + -3.62180376e-21, + -9.64424883e+06, + 4.28617003e+05, + -1.38776651e+04, + -5.67084492e+02, + -1.62419510e+01, + 5.69839732e-01, + -5.51857846e-02, + -4.08412402e-04, + 2.75073882e-05, + 3.22951428e-06, + 2.77947400e-07, + 9.23821913e-09, + 6.02168364e-10, + -1.93974987e-11, + 5.72590308e-13, + 9.28332163e-14, + 2.97267756e-15, + -1.72791692e-17, + 9.67412402e-18, + -3.56358355e-19, + 5.98993189e-20, + 5.46583588e-21, + -6.97473530e-22, + -5.55433409e-23, + -2.84351415e+05, + 1.89427348e+04, + 1.03283471e+02, + -3.13171365e+02, + 6.04679215e+00, + -1.29168004e-01, + -1.55316790e-02, + -5.98319775e-04, + 9.29567386e-05, + -3.46441842e-06, + 1.48197778e-07, + 6.63726139e-09, + 2.36432705e-10, + -5.28895867e-12, + -1.07496078e-12, + 1.79789881e-14, + 6.56014046e-16, + -1.03123494e-16, + 1.70065174e-18, + -1.38964816e-19, + -5.81845026e-21, + 6.59528670e-22, + -5.66145958e-24, + -1.07054750e-24, + 9.62239546e-25, + 3.03931845e+06, + -5.49591963e+04, + 1.80208527e+04, + -2.13457867e+02, + 3.33786918e+01, + 1.20894446e-03, + 5.02031587e-02, + 3.29817378e-04, + 5.90585637e-05, + -5.62712714e-06, + 4.47319790e-09, + 7.69983155e-10, + -1.08877126e-10, + 3.85969685e-12, + -6.22636060e-13, + -6.09633099e-15, + 7.16535361e-17, + -4.49693786e-17, + -7.87534803e-19, + 7.99836812e-21, + -3.19242095e-21, + 5.58142949e-22, + -3.54778091e-23, + 3.10595502e-24, + 1.22326216e-25, + 1.70100073e-26, + 1.46045094e+06, + 4.32263321e+04, + -3.61639253e+03, + 1.76393547e+02, + 2.99865577e+01, + 5.56878362e-01, + 4.64270502e-02, + 2.23135680e-04, + 2.44198356e-05, + -5.09732976e-07, + -5.52998271e-08, + -1.34861262e-09, + -1.26846918e-10, + -4.76375425e-13, + 3.16474633e-14, + -6.53646997e-15, + 1.82135472e-16, + -7.74259603e-18, + -1.04679188e-18, + -2.25527767e-21, + 1.87358845e-21, + -8.17722194e-24, + 8.65116565e-24, + 4.12812316e-25, + -1.28354056e-26, + 1.06941123e-27, + 4.74275544e-28, + 4.64400910e+06, + 1.17762330e+05, + 5.04393993e+03, + 2.41873151e+02, + 1.05991613e+01, + 9.91719254e-01, + 3.31811871e-02, + -1.27925993e-04, + 1.16943118e-05, + 3.68033233e-07, + -4.15168322e-08, + 2.43838825e-10, + -4.33488564e-11, + -2.99950797e-13, + 7.64596212e-14, + -1.26294274e-15, + 3.52120363e-17, + 5.51384664e-19, + -9.38618834e-21, + 3.77916062e-21, + -1.96725331e-22, + 1.00334026e-23, + -6.95787186e-25, + -4.58466777e-26, + -4.85998330e-27, + 7.37967428e-28, + -1.07265976e-28, + 1.30145663e-29, + -4.55311622e+06, + 8.85136386e+04, + -1.22921112e+04, + 6.54963068e+01, + 1.83407219e+00, + 3.62991078e-01, + 1.19259473e-02, + 1.80170327e-04, + -2.32075913e-06, + 3.55679110e-07, + -7.93753446e-09, + 4.08842578e-10, + -2.10847415e-11, + 1.03694161e-12, + -5.77116955e-14, + -1.86801227e-15, + -6.13558266e-17, + 3.81595763e-18, + 1.39824489e-19, + 1.99179530e-21, + -1.69604411e-22, + 2.16654822e-23, + -4.92795189e-26, + 3.15752501e-26, + 3.22486518e-27, + -1.97486311e-28, + 1.55356304e-29, + 1.92882707e-30, + 1.48894626e-31, + -4.44989468e+06, + -2.94601764e+04, + 6.78867912e+03, + 2.08099190e+02, + -2.28374812e+01, + -3.23971095e-01, + 3.53274098e-03, + -2.96292879e-04, + -2.33860045e-05, + -1.48162078e-07, + 2.70060378e-08, + -2.08069817e-10, + -4.44939377e-12, + 1.81426114e-13, + -2.21716795e-14, + -6.87228740e-16, + -7.88578826e-18, + 6.38938895e-19, + -1.09640861e-20, + -1.21122847e-21, + -1.63987359e-22, + -6.78465149e-24, + 2.77070812e-25, + 2.37308290e-27, + -2.10419136e-28, + 4.33317222e-29, + 4.13012822e-30, + -1.42940773e-31, + 3.15975050e-32, + 9.28533670e-33, + -1.37156135e+06, + 5.20607319e+04, + -9.50339983e+03, + 9.36205580e+01, + -3.63047665e-01, + -3.69070656e-02, + -5.64303689e-03, + 2.07616646e-04, + -3.95661432e-06, + -1.46822377e-07, + 1.03701001e-09, + -1.03230597e-10, + 2.97797911e-11, + 9.66602067e-13, + 1.16581740e-14, + -2.48271140e-16, + -2.94535759e-17, + -1.35172908e-18, + -3.53746327e-20, + -5.41091572e-22, + -1.15134914e-22, + -4.47725356e-24, + -7.96360443e-26, + 8.84421015e-28, + 1.86584906e-28, + 1.69983468e-29, + -3.05641417e-31, + -6.05568137e-32, + -6.14441339e-33, + -2.16877172e-35, + -2.48961616e-35, + 1.00695555e+06, + 8.79772773e+04, + 7.08581668e+03, + 1.05953482e+02, + 8.64142936e-01, + 1.69307109e-01, + -7.07468755e-04, + 6.49941325e-05, + 4.35146552e-06, + -1.71843840e-08, + 1.56915321e-09, + 8.31950206e-11, + 4.13096326e-12, + 2.89187411e-13, + -1.08384106e-14, + 2.53484752e-16, + -8.09212652e-18, + -3.43902175e-19, + -2.34324389e-20, + -1.17087884e-22, + -3.67252112e-23, + -6.18984696e-25, + -2.72101379e-26, + 1.84408601e-27, + -1.41164517e-29, + -6.65691234e-30, + -2.00786191e-31, + 1.74841171e-33, + 6.88707941e-34, + -9.18995835e-35, + 2.79637410e-36, + -1.82822004e-36, + 5.74388216e+05, + -8.99016290e+04, + 5.73417695e+03, + -9.83808916e+01, + 5.80578599e-02, + 4.70383100e-01, + -7.13296015e-04, + -5.79652876e-05, + 9.09055317e-06, + 1.65994017e-07, + 2.79784967e-09, + -7.50106407e-11, + -1.02115133e-11, + 2.53810877e-13, + -2.69779200e-15, + 2.76670489e-16, + -1.23958375e-18, + -2.04835267e-20, + 4.08961213e-21, + 6.36579169e-23, + 6.98049148e-24, + -1.92792189e-25, + -1.99454064e-26, + 6.77006990e-28, + -3.69173070e-29, + -3.55813489e-30, + 3.75372652e-32, + -5.72675010e-33, + 3.59717618e-35, + -1.42091123e-36, + -2.33520938e-38, + -8.30547680e-38, + 3.06454481e-38, + -3.42733221e+05, + -3.77953200e+04, + -1.55527788e+03, + -4.53969544e+01, + -2.51644297e+00, + 6.77701248e-02, + 2.35209716e-03, + -1.65982795e-04, + 1.16748986e-06, + 2.12832606e-08, + -2.20997394e-09, + -7.08087566e-11, + -8.98288889e-14, + 4.92854670e-14, + 3.17037618e-15, + -5.06014818e-17, + 1.30554011e-18, + -2.96001125e-20, + -4.72937424e-21, + 1.50549769e-22, + 5.71894252e-24, + 1.04134097e-25, + -9.74871734e-27, + 0.00000000e+00, + 1.24039710e-29, + 3.36356037e-31, + 2.83910112e-32, + -2.30038706e-33, + -6.78522913e-35, + -1.17065592e-35, + 6.44716198e-38, + 2.23163905e-38, + 2.13360820e-39, + -2.48055748e-41, + -2.31527933e+06, + -8.40634372e+04, + -1.82765221e+03, + -3.22997011e+01, + -2.54091191e+00, + -2.08943534e-01, + 4.67177746e-03, + 6.74183711e-05, + -3.43374613e-06, + 7.17567706e-08, + -1.44709437e-09, + 1.39224137e-11, + -1.39357797e-12, + -7.85247141e-14, + -1.59252963e-16, + 1.03819377e-19, + -2.93046053e-19, + 1.15618839e-20, + -3.54842271e-21, + -2.42679452e-23, + 2.54453139e-24, + 1.49741772e-25, + 1.44017594e-27, + 1.38242265e-30, + 6.71362514e-30, + 2.67382673e-31, + 8.54893569e-33, + 3.68703280e-34, + 1.40462888e-37, + -1.78809622e-37, + -1.59629295e-37, + -2.12898678e-39, + 1.91795629e-40, + 3.37907563e-41, + -6.06501518e-42, + 4.55500934e+06, + -1.94903037e+05, + -8.65380118e+03, + -8.35187145e+00, + -3.50368242e+00, + -1.52402306e-01, + 7.42753718e-04, + 1.12994340e-05, + 9.54801687e-07, + 1.80702526e-08, + -1.46687263e-09, + -1.92159752e-11, + -1.08307729e-13, + -1.48811453e-14, + 1.10072253e-16, + -5.88130549e-17, + -3.94161994e-19, + 1.68526499e-20, + 1.10843440e-21, + -5.53719703e-23, + 2.17489864e-25, + 8.23803178e-26, + -7.18111093e-28, + -8.99375830e-29, + 3.37606601e-30, + 7.02943939e-32, + -5.23890666e-33, + 1.22619085e-34, + -8.63215833e-36, + 6.16515022e-37, + -3.15309197e-39, + 5.30098289e-40, + 7.64151174e-43, + 6.71441107e-42, + -3.06848100e-43, + -2.81019006e-44, + -2.13582382e+05, + -1.20004160e+04, + -6.75133177e+02, + -7.88353946e+01, + 1.84825133e+00, + -7.67170439e-02, + -3.93724643e-04, + 5.07915298e-05, + 1.46280756e-06, + 1.55617930e-08, + 1.38603691e-09, + 3.32168028e-11, + -1.47715787e-12, + 1.22086287e-14, + -1.21068825e-15, + 4.86686854e-17, + 5.53440554e-19, + 2.60621292e-20, + 1.22109978e-21, + -1.64269199e-23, + -1.32905579e-24, + 3.45298191e-26, + 1.74509888e-29, + -2.26181094e-29, + 7.22893578e-31, + 2.49108699e-32, + 5.07713759e-34, + -8.31587350e-35, + 2.19219643e-36, + 6.19738759e-38, + -2.90065484e-39, + -2.82845804e-40, + 2.03195083e-41, + 5.10445953e-43, + -4.07858181e-44, + 7.77879497e-46, + 3.89855656e-46, + 0.0, + 0.0, + 0.0, + ) + + // Зональные гармоники + // ano:array[1..37] of extended=( + val ano = + arrayOf( + 0.00000000e+00, + 1.58262324e+08, + 1.01901549e+08, + 1.42437218e+07, + -3.23233386e+07, + 2.18781215e+07, + 1.25821593e+07, + 7.79633402e+06, + 1.39814241e+07, + -1.50906306e+07, + 1.10240855e+07, + 1.42038031e+07, + -7.55206730e+06, + 1.80937330e+05, + -1.09496645e+06, + 7.22071627e+06, + 4.67193453e+06, + -1.29182684e+06, + 9.72393852e+06, + 3.30713367e+06, + -5.79373072e+06, + -9.64424883e+06, + -2.84351415e+05, + 3.03931845e+06, + 1.46045094e+06, + 4.64400910e+06, + -4.55311622e+06, + -4.44989468e+06, + -1.37156135e+06, + 1.00695555e+06, + 5.74388216e+05, + -3.42733221e+05, + -2.31527933e+06, + 4.55500934e+06, + -2.13582382e+05, + 0.0, + 0.0, + ) + + fun anomkond( + fld3: Array, + nzon: Int, + mzon: Int, + xyz: Vector3D, + ): BallisticsError { // &dx : Double, &dy : Double, &dz : Double) : BallisticsErrors{ + xyz.setData(0.0, 0.0, 0.0) + + var k1: Int + var mz: Int + var n: Int + var nn: Int + var mz1: Int + var m1: Int + var k0: Int + var km2: Int + var j1: Int + var c19: Double + var r1: Double + var c20: Double + var r: Double + var sf: Double + var cf: Double + var tf: Double + var djr1: Double + var djm: Double + var djr: Double + var g1: Double + var ac: Double + var bs: Double + var asTek: Double + var bc: Double + var mst: Double + var mst1: Double + var g11: Double + var nnn: Double + var a: Double + var b: Double + var c: Double + var skon: Double + var dxx: Double + var djm1: Double + var xr: Double + var yr: Double + var zr: Double + var dyy: Double + val p = Array(4, { Array(38, { 0.0 }) }) + val sml = Array(40, { 0.0 }) + val cml = Array(40, { 0.0 }) + + mz = mzon + 1 + p[0][0] = 1.0 + + c19 = fld3[3] * fld3[3] + fld3[4] * fld3[4] + r1 = Math.sqrt(c19) + + c20 = c19 + fld3[5] * fld3[5] + r = Math.sqrt(c20) + + sf = fld3[5] / r + cf = r1 / r + + p[2][0] = (3.0 * sf * sf - 1.0) / 2.0 + p[2][1] = 3.0 * sf * cf + p[2][2] = 3.0 * cf * cf + p[1][0] = sf + p[1][1] = cf + tf = sf / cf + djr1 = 0.0 + djm1 = 0.0 + g11 = 0.0 + k1 = 2 + + n = nzon + if (mz >= n) { + mz1 = mz + } else { + mz1 = n + } + + if (mz != 0) { + sml[0] = 0.0 + cml[0] = 1.0 + sml[1] = fld3[4] / r1 + cml[1] = fld3[3] / r1 + } + + if (mz1 >= 3) { + for (i in 3..mz1) { + sml[i - 1] = sml[i - 2] * cml[1] + cml[i - 2] * sml[1] + cml[i - 1] = cml[i - 2] * cml[1] - sml[i - 2] * sml[1] + } + } + + for (j in 2..n) { + k1 = k1 + 1 + if (k1 > 4) { + k1 = k1 - 4 + } + k0 = k1 - 1 + if (k0 == 0) { + k0 = 4 + } + km2 = k0 - 1 + if (km2 == 0) { + km2 = 4 + } + + j1 = j + if (mz == 0) { + j1 = 2 + } + for (i in 1..j1) { + if ((j - 1) < i) { + p[km2 - 1][i - 1] = 0.0 + } + a = 2.0 * (j - 1) + 1.0 + a = a * sf * p[k0 - 1][i - 1] + b = i + j - 2.0 + b = b * p[km2 - 1][i - 1] + c = j - i + 1.0 + p[k1 - 1][i - 1] = (a - b) / c + } + + if (mz != 0) { + a = 2.0 * j - 1.0 + b = cf * p[k0 - 1][j - 1] + p[k1 - 1][j] = a * b + } + djr = 0.0 + djm = 0.0 + g1 = 0.0 + + if (mz == 0) { + m1 = 1 + } else { + m1 = j + 1 + if (j == n) { + m1 = mz + } + } + for (i in 1..m1) { + if ((i - 1) == j) { + p[k1 - 1][i] = 0.0 + } + + if (mz == 0) { + ac = ano[j - 2] + bs = 0.0 + asTek = 0.0 + bc = 0.0 + mst = -p[k1 - 1][i] + mst1 = 0.0 + } else { + nnn = ((1.0 + j) * j) / 2.0 + i + nnn = truncate(nnn) + nn = round(nnn).toInt() + nn -= 3 + ac = anm[nn - 1] * cml[i - 1] + bs = bnm[nn - 1] * sml[i - 1] + asTek = anm[nn - 1] * sml[i - 1] + bc = bnm[nn - 1] * cml[i - 1] + mst1 = i - 1.0 + mst1 = mst1 * p[k1 - 1][i - 1] + mst = i - 1.0 + mst = mst * tf * p[k1 - 1][i - 1] - p[k1 - 1][i] + } + djr = djr + (ac + bs) * p[k1 - 1][i - 1] + djm = djm + (ac + bs) * mst + g1 = g1 + (bc - asTek) * mst1 + } + skon = earth.ekvRadius / r + dxx = skon + for (kk in 1..j) { + skon = skon * dxx + } + a = j + 1.0 + a = a * skon + djr1 = djr1 + djr * a + djm1 = djm1 + djm * skon + g11 = g11 + g1 * skon + } + djr1 = djr1 / r + djm1 = djm1 / r + g11 = g11 / r1 + xr = fld3[3] / r + yr = fld3[4] / r + zr = fld3[5] / r + dxx = djm1 * fld3[5] / r1 + dyy = g11 / r1 + xyz.x = (dxx - djr1) * xr - dyy * fld3[4] + xyz.y = (dxx - djr1) * yr + dyy * fld3[3] + xyz.z = -djr1 * zr - djm1 * r1 / r + + return BallisticsError.OK + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Matrix3D.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Matrix3D.kt new file mode 100644 index 0000000..b4d2adf --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Matrix3D.kt @@ -0,0 +1,58 @@ +package ballistics.utils.math + +class Matrix3D(var first: Vector3D, var second: Vector3D, var third: Vector3D) { + constructor() : this(Vector3D(), Vector3D(), Vector3D()) + + fun firstColumn(): Vector3D { + return Vector3D(first.x, second.x, third.x) + } + + fun secondColumn(): Vector3D { + return Vector3D(first.y, second.y, third.y) + } + + fun thirdColumn(): Vector3D { + return Vector3D(first.z, second.z, third.z) + } + + fun empty() { + first.setData(0.0, 0.0, 0.0) + second.setData(0.0, 0.0, 0.0) + third.setData(0.0, 0.0, 0.0) + } + + fun makeOxMatrix(angle: Double) { + first.setData(1.0, 0.0, 0.0) + second.setData(0.0, Math.cos(angle), -Math.sin(angle)) + third.setData(0.0, Math.sin(angle), Math.cos(angle)) + } + + fun makeOyMatrix(angle: Double) { + first.setData(Math.cos(angle), 0.0, Math.sin(angle)) + second.setData(0.0, 1.0, 0.0) + third.setData(-Math.sin(angle), 0.0, Math.cos(angle)) + } + + fun makeOzMatrix(angle: Double) { + first.setData(Math.cos(angle), -Math.sin(angle), 0.0) + second.setData(Math.sin(angle), Math.cos(angle), 0.0) + third.setData(0.0, 0.0, 1.0) + } + + fun transpose(): Matrix3D { + return Matrix3D( + Vector3D(first.x, second.x, third.x), + Vector3D(first.y, second.y, third.y), + Vector3D(first.z, second.z, third.z), + ) + } + + operator fun times(other: Vector3D) = Vector3D(first * other, second * other, third * other) + + operator fun times(other: Matrix3D) = + Matrix3D( + Vector3D(first * other.firstColumn(), first * other.secondColumn(), first * other.thirdColumn()), + Vector3D(second * other.firstColumn(), second * other.secondColumn(), second * other.thirdColumn()), + Vector3D(third * other.firstColumn(), third * other.secondColumn(), third * other.thirdColumn()), + ) +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Quaternion3D.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Quaternion3D.kt new file mode 100644 index 0000000..7d4313c --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Quaternion3D.kt @@ -0,0 +1,195 @@ +package ballistics.utils.math + +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +class Quaternion3D( + var q0: Double, + var q1: Double, + var q2: Double, + var q3: Double, +) { + constructor() : this(0.0, 0.0, 0.0, 0.0) + + fun makeQuat( + angle: Double, + v: Vector3D, + ) { + var vv = v.basis() + + q0 = cos(angle / 2.0) + q1 = vv.x * sin(angle / 2.0) + q2 = vv.y * sin(angle / 2.0) + q3 = vv.z * sin(angle / 2.0) + + makeBasis() + } + + /*! + норма кватерниона + */ + fun norm() = sqrt(q0 * q0 + q1 * q1 + q2 * q2 + q3 * q3) + + fun makeBasis() { + var n = norm() + q0 /= n + q1 /= n + q2 /= n + q3 /= n + } + + fun basis(): Quaternion3D { + var n = norm() + return Quaternion3D(q0 / n, q1 / n, q2 / n, q3 / n) + } + + fun makeOpposite() { + var n = norm() + q0 /= n + q1 /= -n + q2 /= -n + q3 /= -n + } + + fun opposite(): Quaternion3D { + var n = norm() + return Quaternion3D(q0 / n, -q1 / n, -q2 / n, -q3 / n) + } + + fun module() = acos(basis().q0) * 2.0 + + fun matrix(): Matrix3D { + var x2 = q1 + q1 + var y2 = q2 + q2 + var z2 = q3 + q3 + + var xx = q1 * x2 + var yy = q2 * y2 + var wx = q0 * x2 + + var xy = q1 * y2 + var yz = q2 * z2 + var wy = q0 * y2 + + var xz = q1 * z2 + var zz = q3 * z2 + var wz = q0 * z2 + + return Matrix3D( + Vector3D(1.0 - (yy + zz), xy + wz, xz - wy), + Vector3D(xy - wz, 1.0 - (xx + zz), yz + wx), + Vector3D(xz + wy, yz - wx, 1.0 - (xx + yy)), + ).transpose() + } + + fun fromMatrixStanley(m: Matrix3D) { + val t = m.first.x + m.second.y + m.third.z + + val valueq0 = (1.0 + t) / 4.0 + val valueq1 = (1.0 + 2.0 * m.first.x - t) / 4.0 + val valueq2 = (1.0 + 2.0 * m.second.y - t) / 4.0 + val valueq3 = (1.0 + 2.0 * m.third.z - t) / 4.0 + + if ((valueq0 >= valueq1) && (valueq0 >= valueq2) && (valueq0 >= valueq3)) { + q0 = sqrt(valueq0) + q1 = (m.second.z - m.third.y) / (4.0 * q0) + q2 = (m.third.x - m.first.z) / (4.0 * q0) + q3 = (m.first.y - m.second.x) / (4.0 * q0) + } + + if ((valueq1 >= valueq0) && (valueq1 >= valueq2) && (valueq1 >= valueq3)) { + q1 = sqrt(valueq1) + q0 = (m.second.z - m.third.y) / (4.0 * q1) + q2 = (m.first.y + m.second.x) / (4.0 * q1) + q3 = (m.third.x + m.first.z) / (4.0 * q1) + } + + if ((valueq2 >= valueq0) && (valueq2 >= valueq1) && (valueq2 >= valueq3)) { + q2 = sqrt(valueq2) + q0 = (m.third.x - m.first.z) / (4.0 * q2) + q1 = (m.first.y + m.second.x) / (4.0 * q2) + q3 = (m.second.z + m.third.y) / (4.0 * q2) + } + + if ((valueq3 >= valueq1) && (valueq3 >= valueq2) && (valueq3 >= valueq0)) { + q3 = sqrt(valueq3) + q0 = (m.first.y - m.second.x) / (4.0 * q3) + q1 = (m.third.x + m.first.z) / (4.0 * q3) + q2 = (m.second.z + m.third.y) / (4.0 * q3) + } + + makeBasis() + makeOpposite() + } + + operator fun times(v: Vector3D): Vector3D { + var p = opposite() + var r = Vector3D() + + var a = (q0 + q1) * (v.x) + var b = (q3 - q2) * (v.y - v.z) + var c = (q1 - q0) * (v.y + v.z) + var d = (q2 + q3) * (v.x) + var e = (q1 + q3) * (v.x + v.y) + var f = (q1 - q3) * (v.x - v.y) + var g = (q0 + q2) * (-v.z) + var h = (q0 - q2) * (v.z) + + var qq0 = b + (-e - f + g + h) * 0.5 + var qq1 = a - (e + f + g + h) * 0.5 + var qq2 = -c + (e - f + g - h) * 0.5 + var qq3 = -d + (e - f - g + h) * 0.5 + + a = (qq0 + qq1) * (p.q0 + p.q1) + c = (qq1 - qq0) * (p.q2 + p.q3) + d = (qq2 + qq3) * (p.q1 - p.q0) + e = (qq1 + qq3) * (p.q1 + p.q2) + f = (qq1 - qq3) * (p.q1 - p.q2) + g = (qq0 + qq2) * (p.q0 - p.q3) + h = (qq0 - qq2) * (p.q0 + p.q3) + + r.x = a - (e + f + g + h) * 0.5 + r.y = -c + (e - f + g - h) * 0.5 + r.z = -d + (e - f - g + h) * 0.5 + + return r + } + + operator fun times(other: Quaternion3D): Quaternion3D { + var a = (q0 + q1) * (other.q0 + other.q1) + var b = (q3 - q2) * (other.q2 - other.q3) + var c = (q1 - q0) * (other.q2 + other.q3) + var d = (q2 + q3) * (other.q1 - other.q0) + var e = (q1 + q3) * (other.q1 + other.q2) + var f = (q1 - q3) * (other.q1 - other.q2) + var g = (q0 + q2) * (other.q0 - other.q3) + var h = (q0 - q2) * (other.q0 + other.q3) + + return Quaternion3D( + b + (-e - f + g + h) * 0.5, + a - (e + f + g + h) * 0.5, + -c + (e - f + g - h) * 0.5, + -d + (e - f - g + h) * 0.5, + ) + } + + operator fun plus(other: Quaternion3D): Quaternion3D { + return Quaternion3D( + q0 + other.q0, + q1 + other.q1, + q2 + other.q2, + q3 + other.q3, + ) + } + + operator fun minus(other: Quaternion3D): Quaternion3D { + return Quaternion3D( + q0 - other.q0, + q1 - other.q1, + q2 - other.q2, + q3 - other.q3, + ) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Vector3D.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Vector3D.kt new file mode 100644 index 0000000..84b9ba7 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/Vector3D.kt @@ -0,0 +1,70 @@ +package ballistics.utils.math + +import kotlin.math.sqrt + +class Vector3D(ax: Double, ay: Double, az: Double) { + var x = ax + var y = ay + var z = az + + constructor() : this(0.0, 0.0, 0.0) + + fun setData( + xVal: Double, + yVal: Double, + zVal: Double, + ) { + x = xVal + y = yVal + z = zVal + } + + fun module() = sqrt(x * x + y * y + z * z) + + fun basis(): Vector3D { + var v = Vector3D() + v.x = x / module() + v.y = y / module() + v.z = z / module() + return v + } + + override fun toString(): String { + return "x = $x, y = $y, z = $z" + } + + operator fun plus(other: Vector3D) = Vector3D(x + other.x, y + other.y, z + other.z) + + operator fun minus(other: Vector3D) = Vector3D(x - other.x, y - other.y, z - other.z) + + operator fun times(value: Double) = Vector3D(x * value, y * value, z * value) + + operator fun times(other: Vector3D) = x * other.x + y * other.y + z * other.z + + operator fun rem(other: Vector3D) = + Vector3D( + y * other.z - z * other.y, + z * other.x - x * other.z, + x * other.y - y * other.x, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Vector3D + + if (x != other.x) return false + if (y != other.y) return false + if (z != other.z) return false + + return true + } + + override fun hashCode(): Int { + var result = x.hashCode() + result = 31 * result + y.hashCode() + result = 31 * result + z.hashCode() + return result + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/AbstractEquationCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/AbstractEquationCalculator.kt new file mode 100644 index 0000000..73a29ed --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/AbstractEquationCalculator.kt @@ -0,0 +1,25 @@ +package ballistics.utils.math.equations + +abstract class AbstractEquationCalculator { + var iteration = 0 + var maxIterations = 20 + var delta = 0.001 + var value = 0.0 + + abstract fun calculate( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Double? + + inline fun check( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Boolean { + val y1 = f(x1) + val y2 = f(x2) + + return ((y1 - value) * (y2 - value) <= 0) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/EquationCalculator2Div.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/EquationCalculator2Div.kt new file mode 100644 index 0000000..2633e17 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/EquationCalculator2Div.kt @@ -0,0 +1,48 @@ +package ballistics.utils.math.equations + +import kotlin.math.abs + +class EquationCalculator2Div : AbstractEquationCalculator() { + override fun calculate( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Double? { + if (!check(x1, x2, f)) { + return null + } + iteration = 0 + var xl = x1 + var xr = x2 + + var xmid = (xl + xr) / 2.0 + var ymid = f(xmid) + var y1 = f(xl) + + if ((y1 - value) * (ymid - value) < 0) { + xr = xmid + } else { + xl = xmid + } + + var d = abs(ymid - value) + while ((d > delta) && (iteration < maxIterations)) { + xmid = (xl + xr) / 2.0 + ymid = f(xmid) + y1 = f(xl) + if ((y1 - value) * (ymid - value) < 0) { + xr = xmid + } else { + xl = xmid + } + + d = abs(ymid - value) + iteration++ + } + return if (iteration < maxIterations) { + xmid + } else { + null + } + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/EquationCalculatorSpan.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/EquationCalculatorSpan.kt new file mode 100644 index 0000000..4d2057f --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/equations/EquationCalculatorSpan.kt @@ -0,0 +1,44 @@ +package ballistics.utils.math.equations + +import kotlin.math.abs + +class EquationCalculatorSpan : AbstractEquationCalculator() { + + override fun calculate( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Double? { + if (!check(x1, x2, f)) + return null + + iteration = 0 + var xl = x1 + var xr = x2 + + var yl = f(xl) + var yr = f(xr) + var xmid = xl - ((xr - xl) / ((yr - value) - (yl - value))) * (yl - value) + var ymid = f(xmid) + + var d = abs(ymid - value) * 10000.0 + while ((d > delta * 10000.0) && (iteration < maxIterations)) { + if ((yl - value) * (ymid - value) < 0) { + xr = xmid + yr = ymid + } else { + xl = xmid + yl = ymid + } + xmid = xl - ((xr - xl) / ((yr - value) - (yl - value))) * (yl - value) + ymid = f(xmid) + d = abs(ymid - value) * 10000.0 + iteration++ + } + return if (iteration < maxIterations) { + xmid + } else { + null + } + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/AbstractMinMaxCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/AbstractMinMaxCalculator.kt new file mode 100644 index 0000000..cfece30 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/AbstractMinMaxCalculator.kt @@ -0,0 +1,13 @@ +package ballistics.utils.math.minmax + +abstract class AbstractMinMaxCalculator { + var delta = 0.00001 + var maxIterations = 150 + var sign = 1 // min = 1, max = -1 + + abstract fun calculate( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Double? +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/MinMaxCrushingCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/MinMaxCrushingCalculator.kt new file mode 100644 index 0000000..6da8ec0 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/MinMaxCrushingCalculator.kt @@ -0,0 +1,33 @@ +package ballistics.utils.math.minmax + +class MinMaxCrushingCalculator : AbstractMinMaxCalculator() { + override fun calculate( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Double? { + var step = 1.0 + var iteration = 0 + var xk: Double = (x1 + x2) / 2 + var yk: Double + var yk2: Double + yk = f(xk) + var xk2: Double = xk + step + yk2 = f(xk2) + var d = xk2 - xk + if (d < 0) d *= -1.0 + + while ((d >= delta) && (iteration < maxIterations)) { + if (yk2 * sign > yk * sign) step = -step / 3 + xk = xk2 + yk = yk2 + xk2 = xk + step + yk2 = f(xk2) + d = xk2 - xk + if (d < 0) d *= -1.0 + iteration++ + } + + return if (iteration < maxIterations) xk2 else null + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/MinMaxGoldenSectCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/MinMaxGoldenSectCalculator.kt new file mode 100644 index 0000000..1de04fb --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/utils/math/minmax/MinMaxGoldenSectCalculator.kt @@ -0,0 +1,50 @@ +package ballistics.utils.math.minmax + +import kotlin.math.sqrt + +class MinMaxGoldenSectCalculator : AbstractMinMaxCalculator() { + val tau = (sqrt(5.0) - 1) / 2 + + override fun calculate( + x1: Double, + x2: Double, + f: (Double) -> Double, + ): Double? { + var iteration = 0 + var x: Double? + var xl = x1 + var xr = x2 + var x11: Double = xl + (1 - tau) * (xr - xl) + var x22: Double = xl + tau * (xr - xl) + + var d = x22 - x11 + if (d < 0) d *= -1.0 + + //x = x1 + + var y1 = f(x11) + var y2 = f(x22) + + while (d > delta) { + if (sign * y1 > sign * y2) { + xl = x11 + x11 = x22 + y1 = y2 + x22 = xl + tau * (xr - xl) + y2 = f(x22) + } else { + xr = x22 + x22 = x11 + y2 = y1 + x11 = xl + (1 - tau) * (xr - xl) + y1 = f(x11) + } + d = x22 - x11 + if (d < 0) d *= -1.0 + iteration++ + } + + x = (x11 + x22) / 2 + return if (iteration < maxIterations) x else null + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/zrv/ZRVStepperCalculator.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/zrv/ZRVStepperCalculator.kt new file mode 100644 index 0000000..118d361 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/zrv/ZRVStepperCalculator.kt @@ -0,0 +1,340 @@ +package ballistics.zrv + +import ballistics.orbitalPoints.AbstractOrbPointsCalculator +import ballistics.types.* +import ballistics.utils.earth.getEarth +import ballistics.utils.math.Vector3D +import ballistics.utils.math.equations.EquationCalculator2Div +import ballistics.utils.math.minmax.MinMaxGoldenSectCalculator +import kotlin.math.PI +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Класс расчета ЗРВ КА-ППИ итерационным методом + * Осуществляется проход по точкам орбиты с заданным шагом и формирование участков + * видимости КА-ППИ с уточнением моментов входа и выхода в/из зоны видимости + * + */ +internal class ZRVStepperCalculator(opc: AbstractOrbPointsCalculator, ppi: List) { + var opc: AbstractOrbPointsCalculator = opc + private var ppiExt = mutableListOf() + private var indPPI = 0 + private var step = 60.0 + private val eart = getEarth(opc.earthType) + var ppi = ppi + var zrv = mutableListOf() + + /** + * Расчет радиус-вектора КА в пунктовой СК текущего ППИ + * ППИ определяется по индексу _indPPI + */ + private fun gskToPointSK(point: OrbitalPoint): Vector3D { + val r1 = + Vector3D( + point.r.z - ppiExt[indPPI].params.zn, + point.r.x - ppiExt[indPPI].params.xn, + point.r.y - ppiExt[indPPI].params.yn, + ) + + val r4 = r1.y * ppiExt[indPPI].params.cl + r1.z * ppiExt[indPPI].params.sl + return Vector3D( + ppiExt[indPPI].params.cb * r1.x - ppiExt[indPPI].params.sb * r4, + ppiExt[indPPI].params.sb * r1.x + ppiExt[indPPI].params.cb * r4, + ppiExt[indPPI].params.cl * r1.z - ppiExt[indPPI].params.sl * r1.y, + ) + } + + /** + * Расчет параметров видимости КА-ППИ для заданного положения КА + * ППИ определяется по индексу _indPPI + */ + private fun calculateViewParams(point: OrbitalPoint) { + ppiExt[indPPI].t2 = point.t + val r1 = point.v.x * ppiExt[indPPI].params.cl + point.v.y * ppiExt[indPPI].params.sl + ppiExt[indPPI].vy2 = point.v.z * ppiExt[indPPI].params.sb + r1 * ppiExt[indPPI].params.cb + val r = gskToPointSK(point) + ppiExt[indPPI].gam2 = atan2(r.y, sqrt(r.x * r.x + r.z * r.z)) + ppiExt[indPPI].kaY = r.y + ppiExt[indPPI].a2 = atan2(r.z, r.x) + if (ppiExt[indPPI].a2 < 0) { + ppiExt[indPPI].a2 += 2 * PI + } + } + + /** + * Расчет зон радио-видимости на зааднном интервале времени + */ + fun calculate( + tn: Double, + tk: Double, + ): BallisticsError { + zrv.clear() + if (ppi.isEmpty()) return BallisticsError.EMPTY_PPI_LIST + + var gam1 : Double + var gam2 : Double + + step = 60.0 + + val eqc = EquationCalculator2Div() + val minmax = MinMaxGoldenSectCalculator() + minmax.sign = -1 + minmax.delta = 0.0001 + + eqc.delta = 0.001 * PI / 180.0 + eqc.maxIterations = 50 + ppiExt.clear() + for (p in ppi) { + ppiExt.add( + PPIExt().apply { + params.nip = p.ppiNum + params.au = p.auNum + params.sb = sin(p.lat) + params.cb = cos(p.lat) + params.sl = sin(p.long) + params.cl = cos(p.long) + elevMax = p.elevMax + elevMin = p.elevMin + val r = eart.blh2xyz(p.lat, p.long, p.height) + params.xn = r.x + params.yn = r.y + params.zn = r.z + params.bn = p.lat + shadowMin = p.shadowMin + shadowMax = p.shadowMax + // !!! + }, + ) + } + + val stepper = opc.getStepper() + + val cnt = ppiExt.count() + var ct = tn + + // первая точка интервала расчета + var point = stepper.calculate(tn) + if (point == null) { + return BallisticsError.STEPPER_ERROR + } + for (i in 0..cnt - 1) { + indPPI = i + calculateViewParams(point) + if (ppiExt[i].gam2 > ppiExt[i].elevMin) { + ppiExt[i].zoneIn.t = point.t + ppiExt[i].vit = point.vit + } + } + ct += step + while (ct < tk) { + point = stepper.calculate(ct) + if (point == null) { + return BallisticsError.STEPPER_ERROR + } + for (i in 0..cnt - 1) { + indPPI = i + ppiExt[i].gam1 = ppiExt[i].gam2 + ppiExt[i].t1 = ppiExt[i].t2 + ppiExt[i].vy1 = ppiExt[i].vy2 + ppiExt[i].a1 = ppiExt[i].a2 + + calculateViewParams(point) + + gam1 = ppiExt[i].gam1 + gam2 = ppiExt[i].gam2 + // начало зоны + if (((gam1 < ppiExt[i].elevMin) && (gam2 > ppiExt[i].elevMin)) || + (gam1 > ppiExt[i].elevMin && gam2 > ppiExt[i].elevMin && ppiExt[i].vit == 0) + ) { + eqc.value = ppiExt[i].elevMin + var t = eqc.calculate(ppiExt[i].t1, ppiExt[i].t2, this::elevationAngle) + + if (t != null) { + var buf = calcZoneParams(t) + var it = 0 + while (buf.elevation <= ppiExt[i].elevMin && it < 15) { + t += 0.005 + buf = calcZoneParams(t) + ++it + } + ppiExt[i].zoneIn.t = t + ppiExt[i].vit = point.vit + } else { + println( + """ + Ошибка поиска момента входа в ЗРВ. + ${ppiExt[i].gam1 * 180 / PI} - ${ppiExt[i].elevMin * 180 / PI} - ${ppiExt[i].gam2 * 180 / PI} + """.trimIndent(), + ) + ppiExt[i].zoneIn.t = ppiExt[i].t2 + } + } + // / максимум! + if ((ppiExt[i].vy1 * ppiExt[i].vy2 < 0) && + (ppiExt[i].gam1 > ppiExt[i].elevMin) && + (ppiExt[i].kaY > 0) + ) { + val tmax = minmax.calculate(ppiExt[i].t1, ppiExt[i].t2, this::elevationAngle) + if (tmax == null) { + println("Ошибка при поиске максимума ЗРВ") + ppiExt[i].zoneMax = calcZoneParams((ppiExt[i].t1 + ppiExt[i].t2) / 2) + } else { + ppiExt[i].zoneMax = calcZoneParams(tmax) + } + } + // конец зоны + if (( + (ppiExt[i].gam1 > ppiExt[i].elevMin) && + (gam2 < ppiExt[i].elevMin && ppiExt[i].vit != 0) + ) || + (gam1 < ppiExt[i].elevMin && gam2 < ppiExt[i].elevMin && ppiExt[i].vit != 0) + ) { + eqc.value = ppiExt[i].elevMin + var t = eqc.calculate(ppiExt[i].t1, ppiExt[i].t2, this::elevationAngle) + + if (t != null) { + var buf = calcZoneParams(t) + var it = 0 + while (buf.elevation <= ppiExt[i].elevMin && it < 15) { + t -= 0.005 + buf = calcZoneParams(t) + ++it + } + // ppiExt[i].zoneOut.t = if (buf.elevation<=ppiExt[i].elevMin) t!! else t!! - 0.1 + ppiExt[i].zoneOut.t = t + } else { + println("Ошибка поиска момента выхода в ЗРВ.") + ppiExt[i].zoneOut.t = ppiExt[i].t1 + } + if (ppiExt[i].zoneOut.t * ppiExt[i].zoneIn.t > 1) { + addZone(i) + } else { + + println( + "Ошибка расчета граничной точки зоны ${ppiExt[i].params.nip} ${ppiExt[i].zoneIn.t} ${ + ppiExt[i].zoneOut.t + } ", + ) + } + ppiExt[i].vit = 0 + } + } + ct += step + } + return BallisticsError.OK + } + + private fun addZone(ind: Int) { + // разбиение зоны при наличии затенений + if (ppiExt[ind].elevMax < ppiExt[ind].zoneMax.elevation || + (ppiExt[ind].shadowMin != null && ppiExt[ind].shadowMin!!.toList().isNotEmpty()) || + (ppiExt[ind].shadowMax != null && ppiExt[ind].shadowMax!!.toList().isNotEmpty()) + ) { + var tn: Double? = null + var inZone = false + var inZoneNext = false + var vzp = calcZoneParams(ppiExt[ind].zoneIn.t) + + if (ppiExt[ind].isVisible(vzp.azimuth, vzp.elevation)) { + tn = ppiExt[ind].zoneIn.t + inZone = true + } + val step = 0.1 + var t = ppiExt[ind].zoneIn.t + step + while (t <= ppiExt[ind].zoneOut.t) { + vzp = calcZoneParams(t) + inZoneNext = ppiExt[ind].isVisible(vzp.azimuth, vzp.elevation) + + // конец видимости + if (inZone && !inZoneNext) { + val z = + ZRV( + ppiExt[ind].params.nip, + ppiExt[ind].params.au, + ppiExt[ind].vit, + calcZoneParams(tn!!), + VisibilityParametersZRV( + ppiExt[ind].zoneMax.t, + ppiExt[ind].zoneMax.range, + ppiExt[ind].zoneMax.azimuth, + ppiExt[ind].zoneMax.elevation, + ), + calcZoneParams(t - step), + ) + zrv.add(z) + } + // начало видимости + if (!inZone && inZoneNext) { + tn = t + } + + inZone = inZoneNext + t += step + } + if (inZone && inZoneNext) { + val z = + ZRV( + ppiExt[ind].params.nip, + ppiExt[ind].params.au, + ppiExt[ind].vit, + calcZoneParams(tn!!), + VisibilityParametersZRV( + ppiExt[ind].zoneMax.t, + ppiExt[ind].zoneMax.range, + ppiExt[ind].zoneMax.azimuth, + ppiExt[ind].zoneMax.elevation, + ), + calcZoneParams(ppiExt[ind].zoneOut.t), + ) + zrv.add(z) + } + } else { + val z = + ZRV( + ppiExt[ind].params.nip, + ppiExt[ind].params.au, + ppiExt[ind].vit, + calcZoneParams(ppiExt[ind].zoneIn.t), + calcZoneParams(ppiExt[ind].zoneMax.t), + calcZoneParams(ppiExt[ind].zoneOut.t), + ) +// z.zoneIn.elevation = ppiExt[ind].elevMin +// z.zoneOut.elevation = ppiExt[ind].elevMin + zrv.add(z) + } + } + + fun clear(){ + opc.clear() + zrv.clear() + } + + private fun calcZoneParams(t: Double): VisibilityParametersZRV { + val z = VisibilityParametersZRV() + + val stepper = opc.getStepper() + val point: OrbitalPoint? = stepper.calculate(t) ?: return z + + val r = gskToPointSK(point!!) + + z.t = t + z.elevation = atan(r.y / sqrt(r.x * r.x + r.z * r.z)) + z.range = r.module() + z.azimuth = atan2(r.z, r.x) + if (z.azimuth < 0) { + z.azimuth += 2 * PI + } + return z + } + + private fun elevationAngle(t: Double): Double { + val stepper = opc.getStepper() + val point = stepper.calculate(t) + val r = gskToPointSK(point!!) + return atan(r.y / sqrt(r.z * r.z + r.x * r.x)) + } +} diff --git a/libs/ballistics-lib/src/main/kotlin/ballistics/zrv/ZRVStepperCalculatorAsync.kt b/libs/ballistics-lib/src/main/kotlin/ballistics/zrv/ZRVStepperCalculatorAsync.kt new file mode 100644 index 0000000..02017e9 --- /dev/null +++ b/libs/ballistics-lib/src/main/kotlin/ballistics/zrv/ZRVStepperCalculatorAsync.kt @@ -0,0 +1,363 @@ +package ballistics.zrv + +import ballistics.orbitalPoints.AbstractOrbPointsCalculator +import ballistics.types.* +import ballistics.utils.earth.getEarth +import ballistics.utils.math.Vector3D +import ballistics.utils.math.equations.EquationCalculator2Div +import ballistics.utils.math.minmax.MinMaxGoldenSectCalculator +import reactor.core.publisher.Flux +import reactor.core.publisher.FluxSink +import java.io.IOException +import kotlin.math.PI +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +internal class ZRVStepperCalculatorAsync( + private val opc: AbstractOrbPointsCalculator, + ppi: List, +) { + + private val ppiExt: List + private var step = 60.0 + private val earth = getEarth(opc.earthType) + + init { + ppiExt = + ppi.map { p -> + PPIExt().apply { + params.nip = p.ppiNum + params.au = p.auNum + params.sb = sin(p.lat) + params.cb = cos(p.lat) + params.sl = sin(p.long) + params.cl = cos(p.long) + elevMax = p.elevMax + elevMin = p.elevMin + val r = earth.blh2xyz(p.lat, p.long, p.height) + params.xn = r.x + params.yn = r.y + params.zn = r.z + params.bn = p.lat + shadowMin = p.shadowMin + shadowMax = p.shadowMax + } + } + } + + private fun gskToPointSK( + point: OrbitalPoint, + ppi: PPIExt, + ): Vector3D { + val r1 = + Vector3D( + point.r.z - ppi.params.zn, + point.r.x - ppi.params.xn, + point.r.y - ppi.params.yn, + ) + val r4 = r1.y * ppi.params.cl + r1.z * ppi.params.sl + + return Vector3D( + ppi.params.cb * r1.x - ppi.params.sb * r4, + ppi.params.sb * r1.x + ppi.params.cb * r4, + ppi.params.cl * r1.z - ppi.params.sl * r1.y, + ) + } + + private fun elevationAngle( + t: Double, + ppi: PPIExt, + ): Double { + val stepper = opc.getStepper() + val point = stepper.calculate(t) + val r = gskToPointSK(point!!, ppi) + + return atan(r.y / sqrt(r.z * r.z + r.x * r.x)) + } + + private fun calcZoneParams( + t: Double, + ppi: PPIExt, + ): VisibilityParametersZRV { + val z = VisibilityParametersZRV() + val stepper = opc.getStepper() + val point: OrbitalPoint = stepper.calculate(t) ?: return z + val r = gskToPointSK(point, ppi) + + z.t = t + z.elevation = atan(r.y / sqrt(r.x * r.x + r.z * r.z)) + z.range = r.module() + z.azimuth = atan2(r.z, r.x) + + if (z.azimuth < 0) { + z.azimuth += 2 * PI + } + + return z + } + + private fun checkZone(ppi : PPIExt) = + (ppi.elevMax < ppi.zoneMax.elevation) || + ((ppi.shadowMin?.count() ?: 0) != 0) || + ((ppi.shadowMax?.count() ?: 0) != 0) + + + + private fun parceZone( + sink: FluxSink, + ppi: PPIExt + ){ + var tn: Double? = null + var inZone = false + var inZoneNext = false + var vzp = calcZoneParams(ppi.zoneIn.t, ppi) + + if (ppi.isVisible(vzp.azimuth, vzp.elevation)) { + tn = ppi.zoneIn.t + inZone = true + } + + val step = 0.1 + var t = ppi.zoneIn.t + step + + while (t <= ppi.zoneOut.t) { + vzp = calcZoneParams(t, ppi) + inZoneNext = ppi.isVisible(vzp.azimuth, vzp.elevation) + + // конец видимости + if (inZone && !inZoneNext) { + sink.next( + ZRV( + ppi.params.nip, + ppi.params.au, + ppi.vit, + calcZoneParams(tn!!, ppi), + VisibilityParametersZRV(ppi.zoneMax.t, ppi.zoneMax.range, ppi.zoneMax.azimuth, ppi.zoneMax.elevation), + calcZoneParams(t - step, ppi), + ), + ) + } + + // начало видимости + if (!inZone && inZoneNext) { + tn = t + } + + inZone = inZoneNext + t += step + } + + if (inZone && inZoneNext) { + sink.next( + ZRV( + ppi.params.nip, + ppi.params.au, + ppi.vit, + calcZoneParams(tn!!, ppi), + VisibilityParametersZRV(ppi.zoneMax.t, ppi.zoneMax.range, ppi.zoneMax.azimuth, ppi.zoneMax.elevation), + calcZoneParams(ppi.zoneOut.t, ppi), + ), + ) + } + } + + + private fun addZone( + sink: FluxSink, + ppi: PPIExt, + ) { + // разбиение зоны при наличии затенений + if ( checkZone(ppi) ) { + parceZone(sink, ppi) + } else { + sink.next( + ZRV( + ppi.params.nip, + ppi.params.au, + ppi.vit, + calcZoneParams(ppi.zoneIn.t, ppi), + calcZoneParams(ppi.zoneMax.t, ppi), + calcZoneParams(ppi.zoneOut.t, ppi), + ), + ) + } + } + + private fun calculateViewParams( + point: OrbitalPoint, + ppi: PPIExt, + ) { + ppi.t2 = point.t + + val r1 = point.v.x * ppi.params.cl + point.v.y * ppi.params.sl + ppi.vy2 = point.v.z * ppi.params.sb + r1 * ppi.params.cb + + val r = gskToPointSK(point, ppi) + ppi.gam2 = atan2(r.y, sqrt(r.x * r.x + r.z * r.z)) + ppi.kaY = r.y + ppi.a2 = atan2(r.z, r.x) + + if (ppi.a2 < 0) { + ppi.a2 += 2 * PI + } + } + + fun calculate( + tn: Double, + tk: Double, + ): Flux { + if (ppiExt.isEmpty()) { + return Flux.error(IOException()) // replace with custom exception + } + + var gam1 : Double + var gam2 : Double + + step = 60.0 + + val eqc = EquationCalculator2Div() + val minmax = MinMaxGoldenSectCalculator() + + minmax.sign = -1 + minmax.delta = 0.0001 + + eqc.delta = 0.001 * PI / 180.0 + eqc.maxIterations = 50 + + val stepper = opc.getStepper() + var ct = tn + + return Flux.create { sink -> + // первая точка интервала расчета + var point = stepper.calculate(tn) ?: return@create sink.error(IOException()) + + ppiExt.forEach { + calculateViewParams(point, it) + + if (it.gam2 > it.elevMin) { + it.zoneIn.t = point.t + it.vit = point.vit + } + } + + ct += step + while (ct < tk) { + point = stepper.calculate(ct) ?: return@create sink.error(IOException()) + + ppiExt.forEach { innerPpiLoop -> + innerPpiLoop.gam1 = innerPpiLoop.gam2 + innerPpiLoop.t1 = innerPpiLoop.t2 + innerPpiLoop.vy1 = innerPpiLoop.vy2 + innerPpiLoop.a1 = innerPpiLoop.a2 + + calculateViewParams(point, innerPpiLoop) + + gam1 = innerPpiLoop.gam1 + gam2 = innerPpiLoop.gam2 + // начало зоны + if (((gam1 < innerPpiLoop.elevMin) && (gam2 > innerPpiLoop.elevMin)) || + (gam1 > innerPpiLoop.elevMin && gam2 > innerPpiLoop.elevMin && innerPpiLoop.vit == 0) + ) { + eqc.value = innerPpiLoop.elevMin + var t = + eqc.calculate(innerPpiLoop.t1, innerPpiLoop.t2) { + elevationAngle(it, innerPpiLoop) + } + + if (t != null) { + var buf = calcZoneParams(t, innerPpiLoop) + var it = 0 + + while (buf.elevation <= innerPpiLoop.elevMin && it < 15) { + t += 0.005 + buf = calcZoneParams(t, innerPpiLoop) + ++it + } + + innerPpiLoop.zoneIn.t = t + innerPpiLoop.vit = point.vit + } else { + println( + """ + Ошибка поиска момента входа в ЗРВ. + ${innerPpiLoop.gam1 * 180 / PI} - ${innerPpiLoop.elevMin * 180 / PI} - ${innerPpiLoop.gam2 * 180 / PI} + """.trimIndent(), + ) + innerPpiLoop.zoneIn.t = innerPpiLoop.t2 + } + } + + // / максимум! + if ((innerPpiLoop.vy1 * innerPpiLoop.vy2 < 0) && + (innerPpiLoop.gam1 > innerPpiLoop.elevMin) && + (innerPpiLoop.kaY > 0) + ) { + val tmax = + minmax.calculate(innerPpiLoop.t1, innerPpiLoop.t2) { + elevationAngle(it, innerPpiLoop) + } + + if (tmax == null) { + println("Ошибка при поиске максимума ЗРВ") + innerPpiLoop.zoneMax = calcZoneParams((innerPpiLoop.t1 + innerPpiLoop.t2) / 2, innerPpiLoop) + } else { + innerPpiLoop.zoneMax = calcZoneParams(tmax, innerPpiLoop) + } + } + // конец зоны + if (( + (innerPpiLoop.gam1 > innerPpiLoop.elevMin) && + (gam2 < innerPpiLoop.elevMin && innerPpiLoop.vit != 0) + ) || + ( + gam1 < innerPpiLoop.elevMin && gam2 < innerPpiLoop.elevMin && + innerPpiLoop.vit != 0 + ) + ) { + eqc.value = innerPpiLoop.elevMin + + var t = + eqc.calculate(innerPpiLoop.t1, innerPpiLoop.t2) { + elevationAngle(it, innerPpiLoop) + } + + if (t != null) { + var buf = calcZoneParams(t, innerPpiLoop) + var it = 0 + + while (buf.elevation <= innerPpiLoop.elevMin && it < 15) { + t -= 0.005 + buf = calcZoneParams(t, innerPpiLoop) + ++it + } + + innerPpiLoop.zoneOut.t = t + } else { + println("Ошибка поиска момента выхода в ЗРВ.") + innerPpiLoop.zoneOut.t = innerPpiLoop.t1 + } + + if (innerPpiLoop.zoneOut.t * innerPpiLoop.zoneIn.t > 1) { + addZone(sink, innerPpiLoop) + } else { + println( + "Ошибка расчета граничной точки зоны ${innerPpiLoop.params.nip} ${innerPpiLoop.zoneIn.t} ${ + innerPpiLoop.zoneOut.t + } ", + ) + } + + innerPpiLoop.vit = 0 + } + } + + ct += step + } + + sink.complete() + } + } +} diff --git a/libs/ballistics-lib/src/test/kotlin/Test.kt b/libs/ballistics-lib/src/test/kotlin/Test.kt new file mode 100644 index 0000000..757851d --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/Test.kt @@ -0,0 +1,165 @@ +import ballistics.Ballistics +import ballistics.types.InitialConditions +import ballistics.types.IntegrationType +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import ballistics.utils.toDateTime +import java.time.LocalDateTime +import java.time.ZoneOffset + + +fun main() { + + var ts = System.currentTimeMillis() + val r = Ballistics() + + r.modDVType = ModDVType.BARS // BARS + r.integrationType = IntegrationType.ADAMS7 + + 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, + 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 } + + // r.calculateOrbPoints(mnu.toTypedArray(), mnu.first().point.t, mnu.last().point.t+86400.0 * 5 ) + r.calculateOrbPoints(mnu.toTypedArray(), mnu.first().point.t, mnu.first().point.t + 86400.0 * 5) + + 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}") +// + + ts = System.currentTimeMillis() + r.calculateFlightLine(mnu.first().point.t, mnu.first().point.t + 86400.0 * 5) + println("время расчета = ${System.currentTimeMillis() - ts}мс") + +// var p = r.points.last() +// println("${p.vit} ${LocalDateTime.ofEpochSecond(p.t.toLong(),0, ZoneOffset.UTC)} ${p.x} ${p.y} ${p.z} ${p.vx} ${p.vy} ${p.vz}") +// +// var st = r.getStepper() +// st?.let{ +// var p = it.calculate(nu.point.t + 120.0)//452.4) +// p?.let { +// println( +// "${p.vit} ${ +// LocalDateTime.ofEpochSecond( +// p.t.toLong(), +// 0, +// ZoneOffset.UTC +// ) +// } ${p.x} ${p.y} ${p.z} ${p.vx} ${p.vy} ${p.vz}" +// ) +// } +// } ?: println("is null") +// +// // +// // val ts2 = System.currentTimeMillis() +// var ppi = mutableListOf(PPI(1000, 0, 0.36617007706841037, 1.8417344524829609, +// 0.0, 0.08726646259971647, 1.5707963267948966)) +// try{ +// r.calculateZRV(ppi, mnu.first().point.t, mnu.last().point.t+86400.0 * 5) +// println(r.zrv.size) +// } +// catch(e : Exception){ +// println("err") +// } +// println("время расчета = ${System.currentTimeMillis()-ts}мс ЗРВ : ${r.zrv.size}") +} diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/BallisticsTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/BallisticsTest.kt new file mode 100644 index 0000000..25b1742 --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/BallisticsTest.kt @@ -0,0 +1,86 @@ +package ballistics + +import ballistics.orbitalPoints.timeStepper.TLEStepper +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.InitialConditions +import ballistics.types.OPKatObj +import ballistics.types.TLE +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.PI + +internal class BallisticsTest { + @Test + fun parseTLE() { + var s1 = "1 51824U 22019A 22058.83400146 .00001503 00000-0 10000-3 0 9990" + var s2 = "2 51824 97.5213 135.6262 0019439 268.1769 91.7238 15.08515864 114" + + var s = TLEStepper(s1, s2, EarthType.PZ90d02) + var r = s.baseEpoch + println("${ LocalDateTime.ofEpochSecond(r.toLong(),(r % 1 * 1e9).toInt(), ZoneOffset.UTC)} ") + + var p = s.calculate(r) + + println( + "${p.vit} ${ p.let { LocalDateTime.ofEpochSecond(p.t.toLong(),(p.t % 1 * 1e9).toInt(), ZoneOffset.UTC)} } " + + "${p.r.x} " + + " ${p.r.y} " + + "${p.r.z} " + + " ${p.v.x} " + + "${p.v.y} " + + " ${p.v.z}", + ) + } + + @Test + fun testTLE() { + val tle = +// TLE( +// "1 51824U 22019A 22058.83400146 .00001503 00000-0 10000-3 0 9990", +// "2 51824 97.5213 135.6262 0019439 268.1769 91.7238 15.08515864 114", +// ) + + TLE( + "1 45621U 11037Q 22018.21004500 .07428171 00000-0 19069+0 0 9999", + "2 45621 51.4607 277.9348 0211635 34.9395 326.5259 15.14655454 76661", + ) + + + val bal1 = Ballistics() + val bal2 = Ballistics() + + bal1.sunAngleMin = -90 * PI / 180 + bal2.sunAngleMin = -90 * PI / 180 + + val op = bal1.parseTLE(tle) + + val tn = op.t + val tk = tn + 86400 * 2 + + val r = bal1.calculateOrbPoints(tle, tn, tk) + assertTrue(r == BallisticsError.OK) + bal2.calculateOrbPoints(InitialConditions(op, 0.0, 0.0), tn, tk) + + bal1.calculateFlightLine(tn, tk) + bal2.calculateFlightLine(tn, tk) + + val objs = listOf(OPKatObj(1, 1, "t",1, 60 * PI / 180, 20 * PI / 180, 100.0, 0.0, 90.0, -90.0)) + + bal1.calculateMPL(tn, tk, objs) + bal2.calculateMPL(tn, tk, objs) + + println("${bal1.mpl.count()} - ${bal2.mpl.count()}") + + assertEquals(bal1.mpl.count(), bal2.mpl.count()) + + if (bal1.mpl.toList().isNotEmpty()) { + val v1 = bal1.mpl.first() + val v2 = bal2.mpl.first() + assertTrue(v1.traverz - v2.traverz < 60) + } + } +} diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/flightLine/FlightLineCalculatorTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/flightLine/FlightLineCalculatorTest.kt new file mode 100644 index 0000000..6a564f7 --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/flightLine/FlightLineCalculatorTest.kt @@ -0,0 +1,134 @@ +package ballistics.flightLine + +import ballistics.Ballistics +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.InitialConditions +import ballistics.types.IntegrationType +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.types.WorkCSType +import ballistics.utils.math.Vector3D +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.PI + +internal class FlightLineCalculatorTest { + @Test + fun testFL() { + var r = Ballistics() + + r.earthType = EarthType.PZ90d02 + r.modDVType = ModDVType.FOTO + r.integrationType = IntegrationType.ADAMS7 + r.workCoordinateSystem = WorkCSType.WCSOrbit + r.rollMax = 30 * PI / 180 + + var mnu = mutableListOf() + var tt1 = LocalDateTime.of(2023, 6, 20, 4, 18, 40, 180000000) + var tt2 = LocalDateTime.of(2023, 6, 19, 3, 17, 49, 251000000) + + mnu.add( + InitialConditions( + OrbitalPoint( + tt1.toEpochSecond(ZoneOffset.UTC).toDouble(), // 1687238300.0, + 18721, + Vector3D( + -6603039.949152646, + -1870023.1481177735, + 0.0, + ), + Vector3D( + -401.5289935173786, + 1413.4309568106378, + 7547.69556621523, + ), + ), + 0.014542, + 125.0, + ), + ) + mnu.add( + InitialConditions( + OrbitalPoint( + tt2.toEpochSecond(ZoneOffset.UTC).toDouble(), // 1687238300.0, + 18705, + Vector3D( + -5880984.961878717, + -3535801.795084351, + 0.0, + ), + Vector3D( + -759.5481166352387, + 1257.702405545223, + 7548.463786464257, + ), + ), + 0.014542, + 125.0, + ), + ) + + mnu.sortBy { it.point.t } + + var t = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + var rez = r.calculateOrbPoints(mnu.toTypedArray(), mnu.first().point.t, mnu.first().point.t + 86400.0 * 3) + var t2 = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + println("расчет точек орибты за ${t2 - t} с") + +// for (fl in r.points) +// println("${fl.vit} ${LocalDateTime.ofEpochSecond(fl.t.toLong(),0, ZoneOffset.UTC)} ${fl.r}") + + if (rez == BallisticsError.OK) { + t = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + var rezfl = r.calculateFlightLine(mnu.first().point.t, mnu.first().point.t + 86400.0 * 3) + t2 = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + println("расчет ТП и ПО за ${t2 - t} с") + for (fl in r.flightLine) { + println( + "${fl.vit} ${LocalDateTime.ofEpochSecond(fl.t.toLong(),0, ZoneOffset.UTC)} " + + " ${fl.leftOuterSwath.lat * 180.0 / PI} ${fl.leftOuterSwath.long * 180 / PI} " + + " ${fl.flightLine.lat * 180.0 / PI} ${fl.flightLine.long * 180 / PI} " + + " ${fl.rightOuterSwath.lat * 180.0 / PI} ${fl.rightOuterSwath.long * 180 / PI}", + ) + } +// assertEquals(/* expected = */BallisticsError.OK, /* actual = */rezfl ) + } + + /*var opKatObj = mutableListOf() + var b = -PI/ 2 + var l = 0.0 + while (b <= PI /2){ + while (l <= PI * 2){ + opKatObj.add(OPKatObj(1,1,1,b,l,0.0,0.0,PI/2,0.0)) + l += 0.2 * PI / 180 + } + b += 0.2 * PI / 180 + l = 0.0 + } + println(opKatObj.count()) + t = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + + rez= r.calculateMPL(mnu.first().point.t, mnu.first().point.t+86400.0 * 3, opKatObj) + + t2 = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + + println(r.mpl.count()) + + println("расчет МПЛ за ${t2-t}")*/ + +// println("$rez") +// for (t in r.mpl) +// println("${t.vit} ${LocalDateTime.ofEpochSecond(t.traverz.toLong(),(t.traverz %1 * 1e9).toInt(), ZoneOffset.UTC)} ${t.orientation.kren * 180.0 / PI}" + +// " ${t.range} ${t.sunAngle * 180 / PI}") + + assertEquals( + // expected = + BallisticsError.OK, + // actual = + rez, + ) + } +} diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/orbitalPoints/OrbitalPointsIntegratorTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/orbitalPoints/OrbitalPointsIntegratorTest.kt new file mode 100644 index 0000000..9c0b290 --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/orbitalPoints/OrbitalPointsIntegratorTest.kt @@ -0,0 +1,115 @@ +package ballistics.orbitalPoints + +import ballistics.Ballistics +import ballistics.types.* +import ballistics.utils.math.Vector3D +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.PI + +internal class OrbitalPointsIntegratorTest { + @Test + fun testMDBARS() { + val r = Ballistics() + + r.earthType = EarthType.PZ90d02 + r.modDVType = ModDVType.BARS + r.integrationType = IntegrationType.ADAMS7 + + 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, + 125.0, + ), + ) + val rez = r.calculateOrbPoints(mnu.toTypedArray(), mnu.first().point.t, mnu.first().point.t + 86400.0 * 3) + + for (p in r.revolutions) { + println( + "${p.vuz.vit} ${LocalDateTime.ofEpochSecond(p.vuz.t.toLong(),0, ZoneOffset.UTC)} " + + "${p.lVuz * 180.0 / PI} " + + " ${p.hVuz} " + + "${p.vuz.r.x} " + + " ${p.vuz.r.y} " + + "${p.vuz.r.z} " + + " ${p.vuz.v.x} " + + "${p.vuz.v.y} " + + " ${p.vuz.v.z}", + ) + } + + val p = r.points.last() + 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 rc = Vector3D(r.points.last().r.x, r.points.last().r.y, r.points.last().r.z) + val vc = Vector3D(r.points.last().v.x, r.points.last().v.y, r.points.last().v.z) + + val rex = Vector3D(4723743.916, -4261060.842, -2647685.695) + val vex = Vector3D(-3168.702, 797.721, -6953.781) + + val dr = (rc - rex).module() + val dv = (vex - vc).module() + + println(dr) + println(dv) + + + assertEquals( + BallisticsError.OK, + rez, + ) + assertTrue( + dr < 0.01, + "Ошибка прогноза по радиус-вектору", + ) + assertTrue(dv < 0.01, "ошибка прогноза по вектору скорости") + } + + +// fun initOrekitData() { +// val dataDir = File("src/main/resources/orekit-data-main") // or absolute path +// if (!dataDir.exists()) { +// throw IllegalStateException("Orekit data directory not found at ${dataDir.absolutePath}") +// } +// val crawler = DirectoryCrawler(dataDir) +// DataProvidersManager.getInstance().addProvider(crawler) +// } + +// @Test +// fun orekitTest(){ +// +// initOrekitData() +// +// val l1 = "1 00666U 18111A 25349.99139990 .00002772 00000+0 11883-3 0 9999" +// val l2 = "2 43876 97.3687 252.5788 0001999 83.2822 276.8643 15.23576679386900" +// +// val tle = TLE(l1, l2) +// val sat = Satellite(org.nstart.dep265.tletools.zeptomoby.core.TLE("",l1, l2)) +// +// assertTrue { sat.orbit.satId.toInt() == tle.satelliteNumber } +// } +} diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/types/ModDVTypeTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/types/ModDVTypeTest.kt new file mode 100644 index 0000000..13ebdfe --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/types/ModDVTypeTest.kt @@ -0,0 +1,20 @@ +package ballistics.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ModDVTypeTest { + /*! + Проветка приведения типов от Int к enum + */ + @Test + fun testMDFoto() { + val md1 = ModDVType.fromInt(1) + val md2 = ModDVType.fromInt(16) + val md3 = ModDVType.fromInt(100) + + assertEquals(ModDVType.FOTO, md1) + assertEquals(ModDVType.BARS, md2) + assertEquals(ModDVType.FOTO, md3) + } +} diff --git a/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt b/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt new file mode 100644 index 0000000..a40785a --- /dev/null +++ b/libs/ballistics-lib/src/test/kotlin/ballistics/zrv/ZRVStepperCalculatorTest.kt @@ -0,0 +1,103 @@ +package ballistics.zrv + +import ballistics.Ballistics +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.InitialConditions +import ballistics.types.IntegrationType +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.types.PPI +import ballistics.types.TLE +import ballistics.utils.math.Vector3D +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.PI + +internal class ZRVStepperCalculatorTest { + @Test + fun calculate() { + val r = Ballistics() + + + val p = r.getTLEParams(TLE("1 62141U 24224C 25324.24196334 .00027917 00000-0 10193-2 0 9998", + "2 62141 42.9988 56.0174 0001022 26.8436 333.2469 15.27609213 55582"), "test") + + + + + + r.earthType = EarthType.PZ90d02 + r.modDVType = ModDVType.BARS + r.integrationType = IntegrationType.ADAMS7 + + 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, + 125.0, + ), + ) + var rez = r.calculateOrbPoints(mnu.toTypedArray(), mnu.first().point.t, mnu.first().point.t + 86400.0 * 3) + if (rez == BallisticsError.OK) { + rez = + r.calculateZRV( + listOf( + PPI( + 1, + 1, + 55.15 * PI / 180, + 38.39 * PI / 180, + 0.0, + 5 * PI / 180, + 80 * PI / 180, +// shadowMin = listOf( +// ShadowAU(110.0 * PI / 180, 150 * PI / 180, 25 * PI / 180), +// ShadowAU(175.0 * PI / 180, 183 * PI / 180, 90 * PI / 180), +// ), +// shadowMax = listOf( +// ShadowAU(189.0 * PI / 180, 192 * PI / 180, 0 * PI / 180), +// ) + ), + ), + mnu.first().point.t, + mnu.first().point.t + 86400.0 * 3, + ) + + if (rez == BallisticsError.OK) { + for (z in r.zrv) + println( + " ${z.vit} \t" + + " ${z.au} \t" + + " ${LocalDateTime.ofEpochSecond(z.zoneIn.t.toLong(), 0, ZoneOffset.UTC)} \t" + + " ${z.zoneIn.azimuth * 180 / PI} \t" + + " ${z.zoneIn.elevation * 180 / PI} \t" + + " ${LocalDateTime.ofEpochSecond(z.zoneOut.t.toLong(), 0, ZoneOffset.UTC)} \t" + + " ${z.zoneOut.azimuth * 180 / PI} \t" + + " ${z.zoneOut.elevation * 180 / PI} \t" + + " ${LocalDateTime.ofEpochSecond(z.zoneMax.t.toLong(), 0, ZoneOffset.UTC)} \t" + + " ${z.zoneMax.azimuth * 180 / PI} \t" + + " ${z.zoneMax.elevation * 180 / PI} \t", + ) + } + } + + assertTrue((rez == BallisticsError.OK)) + } +} diff --git a/libs/pcp-types-lib/build.gradle.kts b/libs/pcp-types-lib/build.gradle.kts new file mode 100644 index 0000000..82bb6fd --- /dev/null +++ b/libs/pcp-types-lib/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +group = "space.nstart.pcp" +version = "1.0.0" + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { +// implementation(project(":libs:java-lib")) +// implementation(project(":libs:kotlin-lib")) + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + + implementation("io.github.classgraph:classgraph:${property("versions.classgraph")}") + + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.4") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.locationtech.jts:jts-core:1.19.0") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.named("bootJar") { + enabled = false +} + +tasks.named("jar") { + enabled = true +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/GlobalExceptionHandler.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..816c9e0 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_types_lib.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(WebExchangeBindException::class) + fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { + val errors = mutableMapOf() + ex.bindingResult.fieldErrors.forEach { error -> + errors[error.field] = error.defaultMessage ?: "Validation error" + } + return Mono.just(ResponseEntity.badRequest().body(errors)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/ValidationConfig.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/ValidationConfig.kt new file mode 100644 index 0000000..e00433f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/ValidationConfig.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator { + return Validation.buildDefaultValidatorFactory().validator + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AscNodeDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AscNodeDTO.kt new file mode 100644 index 0000000..369cb5c --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/AscNodeDTO.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(name = "Параметры восходящего узла орбиты") +class AscNodeDTO( + val time : LocalDateTime, + val revolution : Int, + val long : Double, + val height : Double, + val x : Double, + val y : Double, + val z : Double, + val vx : Double, + val vy : Double, + val vz : Double, + val keps : KeplersDTO? = null, + val localMeanTime : LocalDateTime? = null, +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ExactTimePositionRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ExactTimePositionRequestDTO.kt new file mode 100644 index 0000000..af4b454 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ExactTimePositionRequestDTO.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.DecimalMin +import java.time.LocalDateTime + +@Schema(description = "Запрос на расчет параметров выхода на заданное время") +class ExactTimePositionRequestDTO( + val time : LocalDateTime = LocalDateTime.now(), + val timeStop : LocalDateTime? = null, + @field:DecimalMin(value = "1", + message = "Шаг не может быть меньше 1 мс") + val stepMs : Long? = null +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/FlightLineDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/FlightLineDTO.kt new file mode 100644 index 0000000..8a6b066 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/FlightLineDTO.kt @@ -0,0 +1,76 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(description = "Модель данных параметров трассы полета и полосы обзора") +class FlightLineDTO( + @JsonProperty + @field:Schema( + description = "Время", + required = true, + nullable = false + ) + val time : LocalDateTime = LocalDateTime.now(), + @JsonProperty + @field:Schema( + description = "Виток" + ) + val revolution : Long = 0, + @JsonProperty + @field:Schema( + description = "Широта подспутниковой точки, град" + ) + val lat : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Долгота подспутниковой точки, град" + ) + val long : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Широта левой границы полосы обзора" + ) + val latLeft : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Долгота левой границы полосы обзора" + ) + val longLeft : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Широта внутренней левой границы полосы обзора" + ) + val latInnerLeft : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Долгота внутренней левой границы полосы обзора" + ) + val longInnerLeft : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Широта внутренней правой границы полосы обзора" + ) + val latInnerRight : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Долгота внутренней правой границы полосы обзора" + ) + val longInnerRight : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Широта правой границы полосы обзора" + ) + val latRight : Double = 0.0, + @JsonProperty + @field:Schema( + description = "Долгота правой границы полосы обзора" + ) + val longRight : Double = 0.0, + + val revSign : RevolutionSign = RevolutionSign.ASC, + +) { + +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/IcRvaRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/IcRvaRequestDTO.kt new file mode 100644 index 0000000..c34d328 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/IcRvaRequestDTO.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotEmpty +import java.time.LocalDateTime + +class IcRvaRequestDTO( + val ic : InitialConditionsDTO = InitialConditionsDTO(), + + val modDvType : Short? = null, + + @JsonProperty + @field:NotEmpty(message = "Массив станций приема") + val station: StationDTO = StationDTO(), + + val timeStart : LocalDateTime = LocalDateTime.now(), + + val timeStop : LocalDateTime = LocalDateTime.now() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/InitialConditionsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/InitialConditionsDTO.kt new file mode 100644 index 0000000..0a9ea1d --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/InitialConditionsDTO.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +class InitialConditionsDTO( + val orbPoint : OrbPointDTO = OrbPointDTO(), + val sBall : Double = 0.0, + val f81 : Double = 147.8 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/IntervalDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/IntervalDTO.kt new file mode 100644 index 0000000..a5f46f3 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/IntervalDTO.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import java.time.LocalDateTime + +class IntervalDTO( + val begin : LocalDateTime = LocalDateTime.now(), + val end : LocalDateTime = LocalDateTime.now(), + val durationSec : Double = 0.0 +) { +} + + +class DurationOnRevDTO( + var revolution : Long = 0, + val durationSec : Double = 0.0) {} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/KeplersDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/KeplersDTO.kt new file mode 100644 index 0000000..4ca7841 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/KeplersDTO.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +class KeplersDTO( + val ael : Double = 0.0, + val e : Double = 0.0, + val inkl : Double = 0.0, + val omega : Double = 0.0, + val w : Double = 0.0 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ObjDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ObjDTO.kt new file mode 100644 index 0000000..47e56dd --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ObjDTO.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + + +@Schema(description = "Модель данных точечного объекта") +class ObjDTO( + @JsonProperty + @field:Schema( + description = "Идентификатор объекта", + example = "test") + val id : String? = null, + @JsonProperty + @field:Schema( + description = "Положение" + ) + val position : PositionDTO? = null, + val contourWKT : String? = null +) \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ObjViewRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ObjViewRequestDTO.kt new file mode 100644 index 0000000..3253a0c --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/ObjViewRequestDTO.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import java.time.LocalDateTime + +class ObjViewRequestDTO( + val satellites : List = listOf(), + val timeStart : LocalDateTime = LocalDateTime.now(), + val timeStop : LocalDateTime = LocalDateTime.now(), + val obj : ObjDTO = ObjDTO(), + val sunAngleMin : Double? = null, + val rollMin : Double? = null, + val rollMax : Double? = null +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/OrbPointDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/OrbPointDTO.kt new file mode 100644 index 0000000..7a8d81b --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/OrbPointDTO.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import java.time.LocalDateTime + +class OrbPointDTO( + val time : LocalDateTime = LocalDateTime.now(), + val revolution : Long = 0, + val vx : Double = 0.0, + val vy : Double = 0.0, + val vz : Double = 0.0, + val x : Double = 0.0, + val y : Double = 0.0, + val z : Double = 0.0 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/PointViewParamDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/PointViewParamDTO.kt new file mode 100644 index 0000000..666deb8 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/PointViewParamDTO.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import java.time.LocalDateTime + +class PointViewParamDTO( + val noradId : Long = 0, + val objectId : String = "", + val time : LocalDateTime = LocalDateTime.now(), + val revolution : Long = 0, + val gamma : Double = 0.0, + val sunAngle : Double = 0.0, + val range : Double = 0.0, + val revSign : RevolutionSign = RevolutionSign.ASC +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/PositionDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/PositionDTO.kt new file mode 100644 index 0000000..8c76173 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/PositionDTO.kt @@ -0,0 +1,44 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin + + +@Schema(description = "Модель данных положения точки на земной поверхности") +class PositionDTO( + @JsonProperty + @field:Schema( + description = "Широта, град", + example = "46.7" + ) + @field:DecimalMin(value = "${90 * (-1)}", + message = "Широта не должна быть меньше -90 град") + @field:DecimalMax(value = "${90}", + message = "Широта не должна быть больше 90 град") + val lat: Double = 0.0, + + @JsonProperty + @field:Schema( + description = "Долгота, град", + example = "67.89" + ) + @field:DecimalMin(value = "${180 * (-1)}", + message = "Долгота не должна быть меньше -180 град") + @field:DecimalMax(value = "${2 * 180}", + message = "Долгота не должна быть больше 360 град") + val long: Double = 0.0, + + @JsonProperty + @field:Schema( + description = "Превышение, м", + example = "124.67" + ) + @field:DecimalMin(value = "0", + message = "Превышение не должно быть меньше 0 м" + ) + val height: Double = 0.0, +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/RadioVisibilityAreaDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/RadioVisibilityAreaDTO.kt new file mode 100644 index 0000000..53941c5 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/RadioVisibilityAreaDTO.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "Модель данных зоны радио-видимости") +class RadioVisibilityAreaDTO( + val noradId : Long = 0, + val stationId : Long = 0, + val revolution : Long = 0, + val onStart : TargetPositionDTO = TargetPositionDTO(), + val onMaximum : TargetPositionDTO = TargetPositionDTO(), + val onStop : TargetPositionDTO = TargetPositionDTO(), + val duration : Double = 0.0 +) { + +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/RevolutionSign.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/RevolutionSign.kt new file mode 100644 index 0000000..5f204c7 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/RevolutionSign.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + + +enum class RevolutionSign { + ASC, + DESC, +} \ No newline at end of file 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 new file mode 100644 index 0000000..8481b4c --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteICDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +class SatelliteICDTO( + val satelliteId: Long = -1, + val ic : InitialConditionsDTO = InitialConditionsDTO() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteOrbitAvailabilityDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteOrbitAvailabilityDTO.kt new file mode 100644 index 0000000..9ffcc8a --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteOrbitAvailabilityDTO.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import java.time.LocalDateTime + +class SatelliteOrbitAvailabilityDTO( + val satelliteId: Long = 0, + val timeStart: LocalDateTime = LocalDateTime.now(), + val timeStop: LocalDateTime = LocalDateTime.now() +) { +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SquareViewParamDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SquareViewParamDTO.kt new file mode 100644 index 0000000..d194b96 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SquareViewParamDTO.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import java.time.LocalDateTime + + + +class SquareViewParamDTO( + val noradId : Long = 0, + val objectId : String = "", + val revolutionBegin : Long = 0, + val timeBegin : LocalDateTime = LocalDateTime.now(), + val revSignBegin : RevolutionSign = RevolutionSign.ASC, + val revolutionEnd: Long = 0, + val timeEnd : LocalDateTime = LocalDateTime.now(), + val revSignEnd : RevolutionSign = RevolutionSign.ASC, + val gammaMin : Double = 0.0, + val gammaMax : Double = 0.0 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SquareViewParamsWithPointsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SquareViewParamsWithPointsDTO.kt new file mode 100644 index 0000000..83bc73c --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SquareViewParamsWithPointsDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +class SquareViewParamsWithPointsDTO( + val view : SquareViewParamDTO = SquareViewParamDTO(), + val points : List = listOf() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/StationDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/StationDTO.kt new file mode 100644 index 0000000..a34ee1c --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/StationDTO.kt @@ -0,0 +1,57 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import java.util.UUID + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Модель данных наземной станции") +class StationDTO( + + val id : UUID? = null, + + @JsonProperty + @field:Schema( + description = "Идентификатор станции", + example = "1") + val number : Int = 0, + + @JsonProperty + @field:Schema( + description = "Название станции", + example = "КПП") + val name : String = "", + + @JsonProperty + @field:Schema( + description = "Положение" + ) + val position : PositionDTO = PositionDTO(), + @JsonProperty + @field:Schema( + description = "Минимальный угол места, град", + example = "5.0" + ) + @field:DecimalMin(value = "${90 * (-1)}", + message = "Минимальный угол места не должнен быть меньше -90 град") + @field:DecimalMax(value = "${90}", + message = "Минимальный угол места не должен быть больше 90 град") + val elevationMin : Double = 0.0, + + @JsonProperty + @field:Schema( + description = "Максимальный угол места, град", + example = "90" + ) + @field:DecimalMin(value = "${90 * (-1)}", + message = "Максимальный угол места не должен быть меньше -90 град") + @field:DecimalMax(value = "${90}", + message = "Максимальный угол места не должен быть больше 90 град") + val elevationMax : Double = 90.0 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TLEDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TLEDTO.kt new file mode 100644 index 0000000..57aa5be --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TLEDTO.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(name = "Модель данных для TLE") +class TLEDTO( + @JsonProperty + @JsonAlias(value = ["header", "TLE_LINE0"]) + @field:Schema( + description = "Заголовок", + required = false, + nullable = true, + maxLength = 24, + example = "ISS (ZARYA)", + pattern = ".{0,24}" + ) + var header: String? = null, + + @JsonProperty + @JsonAlias(value = ["first", "TLE_LINE1"]) + @field:Schema( + description = "Первая строка данных формата TLE", + required = true, + nullable = false, + minLength = 69, + maxLength = 69, + example = "1 25544S 98067A 98324.2847222 -.00003657 11563-4 00000+0 0 10", + pattern = "(1) (\\d{5})([US ]) (\\d{2})(\\d{3})([A-Z ]{1,3}) (\\d{2})(\\d.{11}) ([-].\\d{8}) ([-+ ]\\d{5}[-+ ]\\d) ([-+ ]\\d{5}[-+ ]\\d) ([0-4]) ([ \\d]{4})(\\d)" + ) + var first: String = " ".repeat(69), + + @JsonProperty + @JsonAlias(value = ["second", "TLE_LINE2"]) + @field:Schema( + description = "Вторая строка данных формата TLE", + required = true, + nullable = false, + minLength = 69, + maxLength = 69, + example = "2 25544 51.5908 168.3788 0125362 86.4185 359.7454 16.05064833 05", + pattern = "(2) (\\d{5}) ([ \\d]{3}.[\\d ]{4}) ([ \\d]{3}.[ \\d]{4}) (\\d{7}) ([ \\d]{3}.[\\d ]{4}) ([ \\d]{3}.[\\d ]{4}) ([ \\d]{2}.[\\d ]{8})([ \\d]{5})(\\d)" + ) + var second: String = " ".repeat(69) +) \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TLEExtensionDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TLEExtensionDTO.kt new file mode 100644 index 0000000..391cf47 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TLEExtensionDTO.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +class TLEExtensionDTO( + val satelliteId : Long = 0, + val revolution : Long = 0, + val tle : TLEDTO = TLEDTO() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TargetPositionDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TargetPositionDTO.kt new file mode 100644 index 0000000..5efc91f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TargetPositionDTO.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + + +@Schema(description = "Модель данных целеуказаний") +class TargetPositionDTO( + val time : LocalDateTime = LocalDateTime.now(), + val elevation : Double = 0.0, + val azimuth : Double = 0.0, + val range : Double = 0.0 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TleRvaRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TleRvaRequestDTO.kt new file mode 100644 index 0000000..050ae8e --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TleRvaRequestDTO.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotEmpty +import java.time.LocalDateTime + +class TleRvaRequestDTO( + val tle : TLEDTO = TLEDTO(), + + @JsonProperty + @field:NotEmpty(message = "Массив станций приема") + val station: StationDTO = StationDTO(), + + val timeStart : LocalDateTime = LocalDateTime.now(), + + val timeStop : LocalDateTime = LocalDateTime.now() + +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageCandidateDebugDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageCandidateDebugDTO.kt new file mode 100644 index 0000000..42717a4 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageCandidateDebugDTO.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp.pcp_types_lib.dto.coverage + +import java.time.LocalDateTime + +class CoverageCandidateDebugDTO( + val satelliteId: Long = 0, + val timeStart: LocalDateTime = LocalDateTime.now(), + val timeEnd: LocalDateTime = LocalDateTime.now(), + val rollDegrees: Double = 0.0, + val accepted: Boolean = false, + val reason: String = "", + val newCoverageArea: Double = 0.0, + val overlapArea: Double = 0.0, + val gapArea: Double = 0.0, + val startBoundary: String? = null +) { +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageObservationDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageObservationDTO.kt new file mode 100644 index 0000000..26b7c50 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageObservationDTO.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.pcp_types_lib.dto.coverage + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO + +class CoverageObservationDTO( + val observation: SquareViewParamDTO = SquareViewParamDTO(), + val intersectionContourWkts: List = listOf() +) { +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageSchemeCalculateRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageSchemeCalculateRequestDTO.kt new file mode 100644 index 0000000..478ba83 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageSchemeCalculateRequestDTO.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_types_lib.dto.coverage + +import java.time.LocalDateTime + +class CoverageSchemeCalculateRequestDTO( + val polygonWkt: String = "", + val satelliteIds: List = listOf(), + val timeStart: LocalDateTime = LocalDateTime.now(), + val timeEnd: LocalDateTime = LocalDateTime.now(), + val rollStepDegrees: Double? = null, + val minimumTechnologyOverlap: Double? = null, + val strictContinuousCoverage: Boolean = true, + val allowPartialCoverage: Boolean? = null, + val orientationToleranceDegrees: Double? = null, + val includeDebugInfo: Boolean = true, + val sunAngle : Double? = null, +) { +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageSchemeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageSchemeResponseDTO.kt new file mode 100644 index 0000000..4566e11 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/coverage/CoverageSchemeResponseDTO.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.coverage + +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO + +class CoverageSchemeResponseDTO( + val targetPolygonWkt: String = "", + val observations: List = listOf(), + val coverageScheme: List = listOf() +) { +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/CellRequestDOT.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/CellRequestDOT.kt new file mode 100644 index 0000000..d97530a --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/CellRequestDOT.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +class CellRequestDOT( + val id : Long = 0, + val requestId : String = "", + val covPercent : Double = 0.0, + val importance : Double = 0.0, + val contour : String = "", +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/ComplexPlanProcessRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/ComplexPlanProcessRequestDTO.kt new file mode 100644 index 0000000..8396b98 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/ComplexPlanProcessRequestDTO.kt @@ -0,0 +1,28 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Min +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import java.time.LocalDateTime + +data class ComplexPlanProcessRequestDTO( + val intervalStart: LocalDateTime, + val intervalEnd: LocalDateTime, + val satelliteId: Long? = null, + val satelliteIds: List = emptyList(), + @field:DecimalMin(value = "-90.0", message = "sun must be between -90 and 90 degrees") + @field:DecimalMax(value = "90.0", message = "sun must be between -90 and 90 degrees") + val sun: Double? = null, + val coverageSource: CoverageCalculationSource? = null, + @field:Min(value = 1, message = "countLat must be greater than 0") + val countLat: Int? = null, + @field:Min(value = 1, message = "countLong must be greater than 0") + val countLong: Int? = null, + val coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY +) + +enum class CoverageCalculationSource { + SLOTS, + COVERAGE_SCHEME +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/CoverageResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/CoverageResponseDTO.kt new file mode 100644 index 0000000..393bc07 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/CoverageResponseDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +class CoverageResponseDTO( + val status: CoverageResponseStatus = CoverageResponseStatus.COVERED, + val scansWKT : List = listOf() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellCalculationRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellCalculationRequestDTO.kt new file mode 100644 index 0000000..72afb39 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellCalculationRequestDTO.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +import java.time.LocalDateTime + +class EarthCellCalculationRequestDTO( + val timeStart : LocalDateTime = LocalDateTime.now(), + val timeStop : LocalDateTime = LocalDateTime.now(), + val cell : EarthCellWithRequestsDTO = EarthCellWithRequestsDTO() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellDTO.kt new file mode 100644 index 0000000..a180597 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellDTO.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +class EarthCellDTO( + val id : Long = 0, + val num : Long = 0, + val latitude : Double = 0.0, + val longitude : Double = 0.0, + val importance : Double = 0.0, + val contour : String = "" +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellWithRequestsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellWithRequestsDTO.kt new file mode 100644 index 0000000..03ffe05 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/EarthCellWithRequestsDTO.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +class EarthCellWithRequestsDTO( + val id : Long = 0, + val num : Long = 0, + val latitude : Double = 0.0, + val longitude : Double = 0.0, + val importance : Double = 0.0, + val contour : String = "", + val requests : Iterable = listOf() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/GeoType.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/GeoType.kt new file mode 100644 index 0000000..5a32692 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/GeoType.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + + +enum class GeoType { + POINT, + POLYGON, +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RequestDTO.kt new file mode 100644 index 0000000..ebee2ef --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RequestDTO.kt @@ -0,0 +1,29 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +@Schema(description = "Заявка для покрытия ячейками") +class RequestDTO( + @field:Schema( + description = "Идентификатор", + example = "aaa-bbb-sss" + ) + val requestId : UUID? = UUID.randomUUID(), + @field:Schema( + description = "Важность заявки", + example = "0.76" + ) + val importance : Double = 0.0, + @field:Schema( + description = "Контур заявки", + example = "POLYGON ((10 10, 15 15, 20 10, 15 5, 10 10))" + ) + val contour : String = "", + + val name : String = "empty", + + val square : Double? = 0.0, + + val geoType : GeoType? = null +) \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RequestWithCellsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RequestWithCellsDTO.kt new file mode 100644 index 0000000..881185d --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RequestWithCellsDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + +class RequestWithCellsDTO( + val request: RequestDTO = RequestDTO(), + val cells: List = listOf() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RevolutionSign.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RevolutionSign.kt new file mode 100644 index 0000000..f61927d --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/RevolutionSign.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests + + +enum class CoverageResponseStatus { + COVERED, + NO_VISIBILITY, + NO_RESOURCES, +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDTO.kt new file mode 100644 index 0000000..36a1df1 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDTO.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +class BookedSlotDTO( + val bookedSlotId : Long = 0, + val satelliteId : Long = 0, + val slotNum : Long = 0, + val cycle : Long = 0 +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotStatus.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotStatus.kt new file mode 100644 index 0000000..847da82 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotStatus.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +enum class BookedSlotStatus { + BOOKED, + PROCESSED, + FINISHED, + CANCELED, + FAILED, + FAILED_TIMEOUT +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotsStatusChangedEvent.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotsStatusChangedEvent.kt new file mode 100644 index 0000000..f902401 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotsStatusChangedEvent.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import java.time.LocalDateTime +import java.util.UUID + +data class BookedSlotsStatusChangedEvent( + val bookedSlotIds: List = emptyList(), + val status: BookedSlotStatus = BookedSlotStatus.BOOKED, + val occurredAt: LocalDateTime = LocalDateTime.now(), + val sourceService: String = "", + val missionId: UUID? = null, + val modeId: Long? = null, + val satelliteId: Long? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookingRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookingRequestDTO.kt new file mode 100644 index 0000000..a514057 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookingRequestDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +class BookingRequestDTO( + val requestId : String = "", + val slots : Iterable = listOf() +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/OccupiedIntervalDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/OccupiedIntervalDTO.kt new file mode 100644 index 0000000..79b77bb --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/OccupiedIntervalDTO.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import java.time.LocalDateTime + +data class OccupiedIntervalDTO( + val satelliteId: Long = 0, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val roll: Double, + val source: String? = null, + val modeId: Long? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SatelliteMissionBatchRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SatelliteMissionBatchRequestDTO.kt new file mode 100644 index 0000000..5e0ec6d --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SatelliteMissionBatchRequestDTO.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import java.time.LocalDateTime + +data class SatelliteMissionBatchRequestDTO( + val satelliteIds: List = emptyList(), + val timeStart: LocalDateTime, + val timeStop: LocalDateTime +) + +data class SatelliteMissionBatchResponseDTO( + val satelliteId: Long = 0, + val slots: List = emptyList() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotBookingRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotBookingRequestDTO.kt new file mode 100644 index 0000000..9a531cf --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotBookingRequestDTO.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +class SlotBookingRequestDTO( + val satelliteId : Long = 0, + val slotNum : Long = 0, + val cycle : Long = 0, + var requestId : String? = "test" +) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotCalculationSummaryDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotCalculationSummaryDTO.kt new file mode 100644 index 0000000..0f22301 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotCalculationSummaryDTO.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import java.time.LocalDateTime + +data class SlotCalculationSummaryDTO( + val satelliteId: Long = 0, + val calculated: Boolean = false, + val slotCount: Long = 0, + val minTime: LocalDateTime? = null, + val maxTime: LocalDateTime? = null, + val minRevolution: Long? = null, + val maxRevolution: Long? = null, + val revolutionInterval: Long? = null, +) { + companion object { + fun calculated( + satelliteId: Long, + slotCount: Long, + minTime: LocalDateTime?, + maxTime: LocalDateTime?, + minRevolution: Long?, + maxRevolution: Long?, + ) = SlotCalculationSummaryDTO( + satelliteId = satelliteId, + calculated = slotCount > 0, + slotCount = slotCount, + minTime = minTime, + maxTime = maxTime, + minRevolution = minRevolution, + maxRevolution = maxRevolution, + revolutionInterval = if (minRevolution != null && maxRevolution != null) { + maxRevolution - minRevolution + } else { + null + }, + ) + } +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotCoverageBatchRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotCoverageBatchRequestDTO.kt new file mode 100644 index 0000000..407db20 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotCoverageBatchRequestDTO.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +data class SlotCoverageBatchRequestDTO( + val items: List = emptyList(), + val timeStart: LocalDateTime, + val timeStop: LocalDateTime, + val revSign: RevolutionSign? = null, + val cov: Boolean? = null, + val satellites: List? = null, + val occupiedIntervals: List = emptyList(), + @field:DecimalMin(value = "-90.0", message = "sun must be between -90 and 90 degrees") + @field:DecimalMax(value = "90.0", message = "sun must be between -90 and 90 degrees") + val sun: Double? = null, + val coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY +) + +enum class SlotCoverageStrategy { + GREEDY, + CONTINUOUS +} + +data class SlotCoverageTargetDTO( + val targetId: Long = 0, + val wkt: String = "" +) + +data class SlotCoverageBatchResponseDTO( + val targetId: Long = 0, + val slots: List = emptyList() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt new file mode 100644 index 0000000..66e64f6 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +class SlotDTO( + val cycle : Long = 0, + val satelliteId : Long = 0, + val tn : LocalDateTime, + val tk : LocalDateTime, + val roll : Double, + val contour : String, + val revolution : Long = 0, + val revolutionSign: RevolutionSign = RevolutionSign.ASC, + val slotNumber : Long = 0, + val state : SlotStatus = SlotStatus.AVAILABLE, + val latitude : Double = 0.0, + val longitude : Double = 0.0, +) \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotStatus.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotStatus.kt new file mode 100644 index 0000000..1d56137 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotStatus.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +enum class SlotStatus{ + AVAILABLE, + BOOKED, + BUSY, + PROCESSED +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SurveySlotDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SurveySlotDTO.kt new file mode 100644 index 0000000..0ec07ed --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SurveySlotDTO.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +class SurveySlotDTO( + val satelliteId : Long = 0, + val tn : LocalDateTime, + val tk : LocalDateTime, + val roll : Double, + val contour : String, + val revolution : Long = 0, + val revolutionSign: RevolutionSign = RevolutionSign.ASC, + val slotIds : List = mutableListOf(), + val latitude : Double = 0.0, + val longitude : Double = 0.0, + ) { +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/ParsedRoutePassportMessageDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/ParsedRoutePassportMessageDTO.kt new file mode 100644 index 0000000..cb02e4f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/ParsedRoutePassportMessageDTO.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp.pcp_types_lib.dto.routes + +/** + * Метаданные входного envelope-сообщения, которые нужно пробросить в выход. + */ +data class RoutePassportMessageMetadata( + val traceId: String, + val correlationId: String, +) + +/** + * Результат разбора входного сообщения: бизнес DTO и метаданные envelope. + */ +data class ParsedRoutePassportMessage( + val metadata: RoutePassportMessageMetadata, + val routePassportDto: RoutePassportDto, +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RawPassportJsonDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RawPassportJsonDTO.kt new file mode 100644 index 0000000..80c56cf --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RawPassportJsonDTO.kt @@ -0,0 +1,86 @@ +package space.nstart.pcp.pcp_types_lib.dto.routes + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonAlias +import java.math.BigDecimal +import java.util.UUID +import tools.jackson.databind.JsonNode + +/** + * Raw DTO блока `properties`. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RawPropertiesDto( + + /** Полное имя платформы, например `sentinel-1a`. */ + val platform: String, + + /** Короткое имя аппарата, например `S1A`. */ + val missionId: String, + + /** Абсолютный номер витка орбиты в исходном JSON. */ + @param:com.fasterxml.jackson.annotation.JsonProperty("route:orbit_number") + val orbitNumber: String, + + /** Направление движения по орбите, например `Descending`. */ + @param:com.fasterxml.jackson.annotation.JsonProperty("sat:orbit_state") + val orbitState: String, + + /** Время начала съёмки в строковом виде. */ + val intervalBegin: String, + + /** Время окончания съёмки в строковом виде. */ + val intervalEnd: String, + + /** Массив значений угла крена. В текущем JSON это диапазон `[min, max]`. */ + val rollAngle: List, + + /** Массив значений угла визирования. В текущем JSON это диапазон `[min, max]`. */ + val visirAngle: List, + + /** Разрешение по дальности в строковом виде, например `1.000000e+01`. */ + val resolutionRange: String, + + /** Разрешение по азимуту в строковом виде, например `1.000000e+01`. */ + val resolutionAzimuth: String, + + /** Список поляризаций. */ + val polarisation: List, + + /** Уровень обработки продукта. */ + @param:com.fasterxml.jackson.annotation.JsonProperty("processing:level") + val processingLevel: String, +) + +/** + * Envelope DTO сообщения, в котором полезная нагрузка маршрута лежит в поле `data`. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RawPassportEnvelopeDto( + @param:JsonAlias("trace_id", "traceid") + val traceId: String = "", + @param:JsonAlias("correlationid") + val correlationId: String = "", + val data: RawFeaturePassportJsonDto, +) + +/** + * Raw DTO STAC Feature внутри envelope-сообщения. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RawFeaturePassportJsonDto( + val id: String, + val routeId: UUID? = null, + val collection: String, + val geometry: RawGeoJsonGeometryDto, + val properties: RawPropertiesDto, +) + +/** + * Raw DTO GeoJSON-геометрии. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RawGeoJsonGeometryDto( + val type: String, + val coordinates: JsonNode, +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt new file mode 100644 index 0000000..f4ca49f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt @@ -0,0 +1,30 @@ +package space.nstart.pcp.pcp_types_lib.dto.routes + +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +data class AngleRangeDto( + val min: BigDecimal, + val max: BigDecimal, +) + +data class RoutePassportDto( + val routeId: UUID, + val kaShort: String, + val kaFull: String, + val routeNameFull: String, + val routeNameShort: String, + val orbitNumber: Long, + val orbitState: String, + val intervalBegin: LocalDateTime, + val intervalEnd: LocalDateTime, + val rollAngle: AngleRangeDto, + val visirAngle: AngleRangeDto, + val resolutionRange: BigDecimal, + val resolutionAzimuth: BigDecimal, + val polarisation: List, + val processingLevel: String, + val geometry: String, + val routeStatus: SurveyModeStatus = SurveyModeStatus.PROCESSED, +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/SurveyModeStatus.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/SurveyModeStatus.kt new file mode 100644 index 0000000..f44017a --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/SurveyModeStatus.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.dto.routes + +enum class SurveyModeStatus { + /** Запланирован */ + PLANNED, + /** Сброшен */ + DROPPED, + /** Обработан */ + PROCESSED, + /** Отменен */ + CANCELED, + /** Что то пошло не так, маршрут непригоден */ + FAILED +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteBatchRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteBatchRequestDTO.kt new file mode 100644 index 0000000..3524f90 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteBatchRequestDTO.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.constraints.NotEmpty + +data class SatelliteBatchRequestDTO( + @field:NotEmpty + val ids: List = listOf() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteCreateDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteCreateDTO.kt new file mode 100644 index 0000000..d99a944 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteCreateDTO.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive + +data class SatelliteCreateDTO( + @field:Positive + val id: Long = 0, + val noradId: Long? = null, + @field:NotBlank + val code: String = "", + @field:NotBlank + val name: String = "", + @field:NotBlank + val typeCode: String = "", + val active: Boolean = true, + val scanTle: Boolean = false, + @field:Valid + val visualization: SatelliteVisualizationDTO = SatelliteVisualizationDTO() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteDTO.kt new file mode 100644 index 0000000..03d0210 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteDTO.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +data class SatelliteDTO( + val id: Long = 0, + val noradId: Long? = null, + val code: String = "", + val name: String = "", + val typeCode: String = "", + val active: Boolean = true, + val scanTle: Boolean = false, + val visualization: SatelliteVisualizationDTO = SatelliteVisualizationDTO(), + val observationProfile: SatelliteObservationProfileDTO? = null, + val slotProfile: SatelliteSlotProfileDTO? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteDeletedEventDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteDeletedEventDTO.kt new file mode 100644 index 0000000..4978642 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteDeletedEventDTO.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +data class SatelliteDeletedEventDTO( + val satelliteId: Long = 0, + val noradId: Long? = null, + val code: String? = null, + val name: String? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupCreateDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupCreateDTO.kt new file mode 100644 index 0000000..7cc2cba --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupCreateDTO.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.constraints.NotBlank + +data class SatelliteGroupCreateDTO( + @field:NotBlank + val name: String = "", + val satelliteIds: List = listOf() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupDTO.kt new file mode 100644 index 0000000..aa9ea90 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +data class SatelliteGroupDTO( + val id: Long = 0, + val name: String = "", + val satelliteIds: List = listOf() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupUpdateDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupUpdateDTO.kt new file mode 100644 index 0000000..7e61220 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteGroupUpdateDTO.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.constraints.NotBlank + +data class SatelliteGroupUpdateDTO( + @field:NotBlank + val name: String = "", + val satelliteIds: List = listOf() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteInfoDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteInfoDTO.kt new file mode 100644 index 0000000..7bb1b0f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteInfoDTO.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +class SatelliteInfoDTO( + val noradId : Long = 0, + val name : String = "", + val red : Short = 255, + val green : Short = 0, + val blue : Short = 0, + val captureAngle: Double = 1.5, + val scanTLE : Boolean = false, + val maxSurveyDurationSeconds: Long? = 300, + val mmiSeconds: Long? = 10, + val code: String? = null, + val typeCode: String? = null, + val active: Boolean = true +) { +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteObservationProfileDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteObservationProfileDTO.kt new file mode 100644 index 0000000..0adea03 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteObservationProfileDTO.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.constraints.PositiveOrZero + +data class SatelliteObservationProfileDTO( + val captureAngle: Double = 1.5, + val sunAngleMin: Double = -90.0, + @field:PositiveOrZero + val durationMinSeconds: Long = 10, + @field:PositiveOrZero + val durationMaxSeconds: Long = 300, + @field:PositiveOrZero + val mmiSeconds: Long = 10, + @field:PositiveOrZero + val dailyMaxDurationSeconds: Long = 35 * 60, + @field:PositiveOrZero + val revolutionMaxDurationSeconds: Long = 5 * 60 +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSlotAngleDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSlotAngleDTO.kt new file mode 100644 index 0000000..06edb14 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSlotAngleDTO.kt @@ -0,0 +1,6 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +data class SatelliteSlotAngleDTO( + val angleBegin: Double = 0.0, + val angleEnd: Double = 0.0 +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSlotProfileDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSlotProfileDTO.kt new file mode 100644 index 0000000..4e63f00 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSlotProfileDTO.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.Valid +import jakarta.validation.constraints.PositiveOrZero +import java.time.LocalDateTime + +data class SatelliteSlotProfileDTO( + @field:PositiveOrZero + val cycleRevs: Long = 0, + val tnCalc: LocalDateTime? = null, + @field:PositiveOrZero + val slotDuration: Long = 10, + @field:PositiveOrZero + val durationCalcDays: Long = 0, + @field:PositiveOrZero + val maxChainLength: Int = 0, + @field:Valid + val defaultAngles: List = listOf() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSummaryDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSummaryDTO.kt new file mode 100644 index 0000000..b795e2d --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteSummaryDTO.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +data class SatelliteSummaryDTO( + val id: Long = 0, + val noradId: Long? = null, + val code: String = "", + val name: String = "", + val typeCode: String = "", + val active: Boolean = true, + val scanTle: Boolean = false, + val visualization: SatelliteVisualizationDTO = SatelliteVisualizationDTO() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteUpdateDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteUpdateDTO.kt new file mode 100644 index 0000000..e49ff65 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteUpdateDTO.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank + +data class SatelliteUpdateDTO( + val noradId: Long? = null, + @field:NotBlank + val code: String = "", + @field:NotBlank + val name: String = "", + @field:NotBlank + val typeCode: String = "", + val active: Boolean = true, + val scanTle: Boolean = false, + @field:Valid + val visualization: SatelliteVisualizationDTO = SatelliteVisualizationDTO() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteVisualizationDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteVisualizationDTO.kt new file mode 100644 index 0000000..90d036d --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/SatelliteVisualizationDTO.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min + +data class SatelliteVisualizationDTO( + @field:Min(0) + @field:Max(255) + val red: Short = 255, + @field:Min(0) + @field:Max(255) + val green: Short = 0, + @field:Min(0) + @field:Max(255) + val blue: Short = 0 +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionRequestDTO.kt new file mode 100644 index 0000000..0f5e387 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionRequestDTO.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size +import java.time.LocalDateTime +import java.util.UUID + +data class MissionRequestDTO( + val missionId: UUID? = UUID.randomUUID(), + + @field:Positive + val satelliteId: Long, + + @field:Size(max = 32) + val station: String? = null, + + val missionStart: LocalDateTime? = null, + val missionStop: LocalDateTime? = null, + + @field:Size(max = 15) + val status: String? = null +) + +data class MissionResponseDTO( + val missionId: UUID, + val satelliteId: Long, + val station: String?, + val missionStart: LocalDateTime?, + val missionStop: LocalDateTime?, + val status: String +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionStatisticsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionStatisticsDTO.kt new file mode 100644 index 0000000..66c6e29 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionStatisticsDTO.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import java.time.LocalDate + +data class MissionStatisticsDTO( + val modesCount: Int = 0, + val totalContourArea: Double = 0.0, + val revolutionDurations: List = emptyList(), + val dailyDurations: List = emptyList() +) + +data class RevolutionDurationDTO( + val revolution: Long = 0, + val totalDuration: Double = 0.0 +) + +data class DailyDurationDTO( + val date: LocalDate = LocalDate.MIN, + val totalDuration: Double = 0.0 +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionSurveyCalculationResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionSurveyCalculationResponseDTO.kt new file mode 100644 index 0000000..689703a --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/MissionSurveyCalculationResponseDTO.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import java.time.LocalDateTime +import java.util.UUID + +data class MissionSurveyCalculationResponseDTO( + val missionId: UUID, + val completed: Boolean, + val startedAt: LocalDateTime, + val finishedAt: LocalDateTime, + val durationMs: Long, + val comPlanSnapshotId: Long, + val surveyModesCount: Int, + val manualModesCount: Int, + val complanModesCount: Int, + val slotsModesCount: Int, + val mixedModesCount: Int, + val bookedSlotIdsCount: Int +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt new file mode 100644 index 0000000..a14befc --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt @@ -0,0 +1,69 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import io.swagger.v3.oas.annotations.media.Schema +import java.time.OffsetDateTime + +@Schema( + description = "Базовая модель режима", + discriminatorProperty = "type", + oneOf = [SurveyModeResponseDTO::class, DropModeResponseDTO::class] +) +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true +) +@JsonSubTypes( + JsonSubTypes.Type(value = SurveyModeResponseDTO::class, name = "SURVEY"), + JsonSubTypes.Type(value = DropModeResponseDTO::class, name = "DROP") +) +sealed interface ModeResponseDTO { + @get:Schema(description = "Идентификатор режима") + val id: Long + + @get:Schema(description = "Идентификатор плана") + val planId: Long + + @get:Schema(description = "Время начала режима") + val timeStart: OffsetDateTime? + + @get:Schema(description = "Номер витка") + val revolution: Long + + @get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"]) + val type: String +} + +@Schema(description = "Режим съемки") +data class SurveyModeResponseDTO( + override val id: Long = 0, + override val planId: Long = 0, + override val timeStart: OffsetDateTime? = null, + override val revolution: Long = 0, + override val type: String = "SURVEY", + val status: String = "", + val satelliteModeId: Long? = null, + val source: String = "", + val lat: Double = 0.0, + val longitude: Double =0.0, + val duration: Double = 0.0, + val contourWkt: String? = "", + val roll: Double = 0.0, + @get:Schema(description = "Идентификаторы забронированных слотов") + val bookedSlotIds: List = emptyList() +) : ModeResponseDTO + +@Schema(description = "Режим сброса") +data class DropModeResponseDTO( + override val id: Long = 0, + override val planId: Long = 0, + override val timeStart: OffsetDateTime? = null, + override val revolution: Long = 0, + override val type: String = "DROP", + val station: String? = "", + val duration: Double = 0.0, + val surveys: List = emptyList() +) : ModeResponseDTO diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt new file mode 100644 index 0000000..ef23741 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import java.time.LocalDateTime + +data class SatelliteModeResponseDTO( + val id: Long = 0, + val satelliteId: Long = 0, + val source: String = "", + val type: String = "SURVEY", + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val revolution: Long = 0, + val lat: Double = 0.0, + val longitude: Double = 0.0, + val duration: Double = 0.0, + val contourWkt: String? = null, + val roll: Double = 0.0, + val cellNum: Long = 0, + val cellNums: List = emptyList(), + val bookedSlotIds: List = emptyList(), + val snapshotId: Long? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeSnapshotResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeSnapshotResponseDTO.kt new file mode 100644 index 0000000..f98df1c --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeSnapshotResponseDTO.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.LocalDateTime + +data class SatelliteModeSnapshotResponseDTO( + val id: Long = 0, + val satelliteId: Long = 0, + val intervalStart: LocalDateTime, + val intervalEnd: LocalDateTime, + val createdAt: LocalDateTime, + @param:JsonProperty("isActive") + @get:JsonProperty("isActive") + val isActive: Boolean = false, + val complexPlanRunId: Long? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/modes/SurveyModeInfoDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/modes/SurveyModeInfoDTO.kt new file mode 100644 index 0000000..73a4b46 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/modes/SurveyModeInfoDTO.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.modes + +import java.time.LocalDateTime + +data class SurveyModeInfoDTO( + val revolution : Long = 0, + val time : LocalDateTime = LocalDateTime.now(), + val timStop : LocalDateTime = LocalDateTime.now(), + val duration : Double = 0.0, + val roll : Double = 0.0, + val contourWKT : String = "", + val slotIds : List = mutableListOf(), + val latitude : Double = 0.0, + val longitude : Double = 0.0, +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaEvent.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaEvent.kt new file mode 100755 index 0000000..2a79aa1 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaEvent.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.message + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import space.nstart.pcp.pcp_types_lib.utils.json.KafkaEventDeserializer +import space.nstart.pcp.pcp_types_lib.utils.json.KafkaEventSerializer + +@JsonDeserialize(using = KafkaEventDeserializer::class) +@JsonSerialize(using = KafkaEventSerializer::class) +sealed interface KafkaEvent { + fun enumCast() = this as? Enum<*> +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt new file mode 100644 index 0000000..0e55531 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt @@ -0,0 +1,29 @@ +package space.nstart.pcp.pcp_types_lib.message + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import space.nstart.pcp.pcp_types_lib.utils.json.LocalDateTimeTimeZoneSerializer +import java.time.LocalDateTime +import java.util.* + + +class KafkaMessage ( + val type: KafkaEvent, + val data: T, + @param:JsonAlias("trace_id", "traceid") + val traceId: String = UUID.randomUUID().toString(), + @param:JsonAlias("correlationid") + val correlationId: String = UUID.randomUUID().toString(), + val group: String? = null, +) { + val id = UUID.randomUUID().toString() + var source = "Example" + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") + @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) + val time: LocalDateTime = LocalDateTime.now() + + val dataContentType = "application/json" + val specVersion = "1.0" +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/PcpKafkaEvent.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/PcpKafkaEvent.kt new file mode 100644 index 0000000..b98dfae --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/PcpKafkaEvent.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_types_lib.message + +enum class PcpKafkaEvent : KafkaEvent{ + ICPlacedEvent, + ICUpdatedEvent, + ICRVPlacedEvent, + BookedSlotsStatusChangedEvent, + /** Поступление маршрута для геопривязки */ + RouteGeoRefEvent, + /** Изменения статуса включения */ + ModeStatusChangedEvent, + /** Поступление заявки на съемку */ + RequestPlacedEvent, + /** Удаление спутника из каталога */ + SatelliteDeletedEvent, +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/KafkaEventClassDiscoverer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/KafkaEventClassDiscoverer.kt new file mode 100644 index 0000000..62b15fa --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/KafkaEventClassDiscoverer.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.pcp_types_lib.utils + +import io.github.classgraph.ClassGraph +import space.nstart.pcp.pcp_types_lib.message.KafkaEvent +import kotlin.reflect.KClass + +class KafkaEventClassDiscoverer { + companion object { + const val KAFKA_EVENTS_PACKAGE = "space.nstart.pcp.pcp_types_lib.message" + + fun discoverKafkaEventEnums(): List> { + ClassGraph() + .enableClassInfo() + .enableInterClassDependencies() + .acceptPackages(KAFKA_EVENTS_PACKAGE) + .scan() + .use { scanResult -> + return scanResult + .getClassesImplementing(KafkaEvent::class.qualifiedName) + .filter { it.isEnum } + .map { it.loadClass().kotlin } + .filterIsInstance>() + } + } + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/KafkaEventDeserializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/KafkaEventDeserializer.kt new file mode 100644 index 0000000..8e728f4 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/KafkaEventDeserializer.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import space.nstart.pcp.pcp_types_lib.message.KafkaEvent +import space.nstart.pcp.pcp_types_lib.utils.KafkaEventClassDiscoverer + +class KafkaEventDeserializer: JsonDeserializer() { + private val enumClasses by lazy { KafkaEventClassDiscoverer.discoverKafkaEventEnums() } + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): KafkaEvent { + val value = p.valueAsString + + for (enumClass in enumClasses) { + try { + enumClass.java.enumConstants + ?.find { it.enumCast()?.name == value } + ?.let { enumValue -> return enumValue } + } catch (e: Exception) { + // Continue to next class + } + } + + throw IllegalArgumentException("Unknown KafkaEvent value: $value. Available values: ${getAllPossibleValues()}") + } + + private fun getAllPossibleValues(): List { + return enumClasses.flatMap { it.java.enumConstants.mapNotNull { enum -> enum.enumCast()?.name } } + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/KafkaEventSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/KafkaEventSerializer.kt new file mode 100755 index 0000000..e90e653 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/KafkaEventSerializer.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import space.nstart.pcp.pcp_types_lib.message.KafkaEvent + +class KafkaEventSerializer: JsonSerializer() { + override fun serialize(value: KafkaEvent, gen: JsonGenerator, serializers: SerializerProvider) { + gen.writeString(value.enumCast()?.name) + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt new file mode 100755 index 0000000..42b54ad --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class LocalDateTimeTimeZoneSerializer: JsonSerializer() { + + companion object { + val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx") + } + + override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { + + //todo: проверить корректность зоны по нормальным правилам +// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt)) + p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt)) + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/wkt/WKTParser.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/wkt/WKTParser.kt new file mode 100644 index 0000000..0829c5e --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/wkt/WKTParser.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_types_lib.utils.wkt + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.io.WKTWriter + +class WKTParser { + private val wktReader = WKTReader() + private val wktWriter = WKTWriter() + fun parseWKT(wkt: String): Geometry { + return wktReader.read(wkt) + } + fun toWKT(geometry: Geometry): String { + return wktWriter.write(geometry) + } +} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/PcpTypesLibApplicationTests.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/PcpTypesLibApplicationTests.kt new file mode 100644 index 0000000..f35e2f5 --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/PcpTypesLibApplicationTests.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_types_lib + +import org.junit.jupiter.api.Test + +class PcpTypesLibApplicationTests { + + @Test + fun contextLoads() { + } + +} diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt new file mode 100644 index 0000000..7fd1aad --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt @@ -0,0 +1,53 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class ModeResponseDTOJacksonTest { + + private val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + + @Test + fun `deserializes mixed mission modes by type`() { + val json = """ + [ + { + "id": 1, + "planId": 10, + "timeStart": "2026-03-17T08:00:00Z", + "revolution": 100, + "type": "SURVEY", + "source": "AUTO", + "lat": 55.75, + "longitude": 37.62, + "duration": 12.5, + "contourWkt": null, + "roll": 1.5, + "bookedSlotIds": [101, 102] + }, + { + "id": 2, + "planId": 10, + "timeStart": "2026-03-17T09:00:00Z", + "revolution": 101, + "type": "DROP", + "station": "S1", + "duration": 3.0, + "surveys": [1] + } + ] + """.trimIndent() + + val result: List = mapper.readValue(json) + + assertEquals(2, result.size) + assertInstanceOf(SurveyModeResponseDTO::class.java, result[0]) + assertInstanceOf(DropModeResponseDTO::class.java, result[1]) + assertEquals("SURVEY", result[0].type) + assertEquals("DROP", result[1].type) + } +} diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessageSerializationTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessageSerializationTest.kt new file mode 100644 index 0000000..14cc2d5 --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessageSerializationTest.kt @@ -0,0 +1,85 @@ +package space.nstart.pcp.pcp_types_lib.message + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +class KafkaMessageSerializationTest { + + private val objectMapper = jacksonObjectMapper().findAndRegisterModules() + + @Test + fun `should serialize traceId and correlationId without changing values`() { + val message = KafkaMessage( + type = PcpKafkaEvent.RouteGeoRefEvent, + data = RoutePassportDto( + routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"), + kaShort = "S1A", + kaFull = "sentinel-1a", + routeNameFull = "ROUTE-001", + routeNameShort = "S1A-GRDH", + orbitNumber = 59796L, + orbitState = "Descending", + intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0), + intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30), + rollAngle = AngleRangeDto(BigDecimal("-15.5"), BigDecimal("-12.0")), + visirAngle = AngleRangeDto(BigDecimal("21.0"), BigDecimal("24.5")), + resolutionRange = BigDecimal("10.0"), + resolutionAzimuth = BigDecimal("10.0"), + polarisation = listOf("VH", "VV"), + processingLevel = "GRD", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))", + ), + traceId = "trace-out", + correlationId = "corr-out", + group = "pcp", + ) + + val json = objectMapper.readTree(objectMapper.writeValueAsString(message)) + + assertEquals("trace-out", json.path("traceId").asText()) + assertEquals("corr-out", json.path("correlationId").asText()) + assertEquals("", json.path("trace_id").asText()) + assertEquals("", json.path("correlationid").asText()) + } + + @Test + fun `should deserialize legacy trace and correlation aliases`() { + val json = """ + { + "type": "RouteGeoRefEvent", + "data": { + "routeId": "51b07d12-1823-4bd5-b55d-de02a5304c03", + "kaShort": "S1A", + "kaFull": "sentinel-1a", + "routeNameFull": "ROUTE-001", + "routeNameShort": "S1A-GRDH", + "orbitNumber": 59796, + "orbitState": "Descending", + "intervalBegin": "2026-04-01T10:15:00", + "intervalEnd": "2026-04-01T10:16:30", + "rollAngle": {"min": -15.5, "max": -12.0}, + "visirAngle": {"min": 21.0, "max": 24.5}, + "resolutionRange": 10.0, + "resolutionAzimuth": 10.0, + "polarisation": ["VH", "VV"], + "processingLevel": "GRD", + "geometry": "POLYGON((0 0, 1 0, 1 1, 0 0))" + }, + "trace_id": "trace-legacy", + "correlationid": "corr-legacy" + } + """.trimIndent() + + val message: KafkaMessage = objectMapper.readValue(json) + + assertEquals("trace-legacy", message.traceId) + assertEquals("corr-legacy", message.correlationId) + } +} diff --git a/schemes/pcp_v1.bpmn b/schemes/pcp_v1.bpmn new file mode 100644 index 0000000..a44a37d --- /dev/null +++ b/schemes/pcp_v1.bpmn @@ -0,0 +1,682 @@ + + + + + + + + + + Flow_1j6oiu4 + + + + Flow_1j6oiu4 + Flow_11h9xxc + + + Flow_11h9xxc + Flow_0u6h1bu + Flow_1ojy33x + + DataStoreReference_033nof8 + + + + Flow_0u6h1bu + Flow_0mwo4aw + + + + Flow_0mwo4aw + Flow_0swltbn + + DataStoreReference_1q8xush + + + + Flow_1c0do0v + Flow_11kxu4w + + DataStoreReference_0giy8kf + + + + Flow_11kxu4w + + + Flow_1ojy33x + Flow_1c0do0v + + + + + + + Flow_0swltbn + Flow_1r12z6o + + + Flow_1r12z6o + Flow_0hz9u31 + Flow_03041us + + + Flow_1yr191p + + + Flow_03041us + + + Flow_0hz9u31 + Flow_1yr191p + + + + + + + + + + + + + + + + + + Flow_1dvc9dq + + + Flow_1dvc9dq + Flow_162v35v + + + DataStoreReference_1q8xush + Property_101aglh + + + + Flow_162v35v + Flow_1qjf02f + + + Flow_1qjf02f + Flow_09tybrl + + + Flow_09tybrl + Flow_1cnk8wu + + DataStoreReference_0cxqgp6 + + + + + Flow_1cnk8wu + Flow_0clmx8k + + + + Flow_0clmx8k + + + + + + + + + + + Flow_0qmeo32 + + + Flow_1t22qnh + Flow_0gg2wyl + + + Flow_1smbh8x + Flow_1rkj9vu + + + Flow_14ty6xr + Flow_0j89ljo + + + Flow_0j89ljo + Flow_1hkg7pd + + + Flow_1f71jzk + Flow_0inz4wc + + + Flow_0gg2wyl + Flow_1rkj9vu + Flow_1hkg7pd + Flow_0inz4wc + Flow_1ok6scm + + + Flow_0rnqbqb + Flow_1t22qnh + Flow_1smbh8x + Flow_14ty6xr + Flow_1f71jzk + + + Flow_0qmeo32 + Flow_0rnqbqb + + + Flow_1xn90q1 + Flow_0h32tgk + Flow_1xz3ivt + + + Flow_09mjyls + + + Flow_0h32tgk + Flow_1dofvi5 + Flow_1ib33g8 + Flow_1uvigur + + + Flow_1eavsnw + Flow_1ykflw9 + Flow_1h69p26 + Flow_09mjyls + + + Flow_1dofvi5 + Flow_1eavsnw + + + + Flow_1ib33g8 + Flow_1h69p26 + + + + Flow_1uvigur + Flow_1ykflw9 + + + + Flow_1xz3ivt + Flow_1tv2d50 + + + + + + + Flow_1tv2d50 + Flow_1k77es3 + + + Flow_1ok6scm + Flow_1ey73bf + + + Flow_1ey73bf + Flow_1fnl9s6 + + + Flow_1fnl9s6 + Flow_19n7tmk + + + Flow_19n7tmk + Flow_1k77es3 + Flow_1xn90q1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services-description.md b/services-description.md new file mode 100644 index 0000000..7a8abe7 --- /dev/null +++ b/services-description.md @@ -0,0 +1,2078 @@ +# Service Landscape Report + +**Источник:** архив `pcp.zip` +**Корень анализа:** `pcp/` +**Дата анализа:** 2026-04-28 +**Метод:** статический анализ Gradle-модулей, `config-repo`, REST-контроллеров, Kafka listeners/producers, Camunda/Zeebe workers, JPA entities, Flyway migrations, сервисных классов и DTO. Сборка и запуск сервисов не выполнялись. + +## 1. Executive summary + +В архиве находится multi-module Kotlin/Spring Boot система PCP с набором backend-сервисов, UI/BFF, общими библиотеками DTO/баллистики, Spring Cloud Config server и Spring Boot Admin. Основной домен системы — работа с заявками на съемку/наблюдение, спутниками, орбитальными расчетами, слотами доступности, комплексным планированием, миссиями и передачей/подтверждением планов. + +Ключевые бизнес-возможности: + +- ведение заявок на съемку и их геометрий; +- ведение каталога спутников, групп и профилей съемки/слотов; +- загрузка и хранение TLE, расчет орбитальных производных данных; +- расчет зон видимости, flight line, RVA, MPL-кандидатов и покрытий; +- расчет и бронирование слотов; +- построение комплексного плана и снимков режимов КА; +- планирование миссий, режимов съемки/сброса и подтверждение планов; +- интеграция с Camunda/Zeebe для процессов спутникового плана; +- UI/BFF для карты, каталога, заявок, слотов, станций и complex-plan. + +Главная архитектурная картина: + +- `pcp-request-service` владеет заявками, сеткой Земли и route-matching состоянием. +- `pcp-satellite-catalog-service` владеет справочником спутников и профилей. +- `tle-monitoring-service` владеет исходными TLE и публикует события TLE. +- `pcp-ballistics-service` владеет производными баллистическими данными: PDCM, asc nodes, flight line, покрытиями и cell covering. +- `slots-service` владеет слотами, бронированиями и initial conditions для слотов. +- `pcp-complex-mission-service` хранит старую/основную модель комплексной миссии: satellite modes, snapshots, complex plan run. +- `pcp-dynamic-plan-service` реализует отдельный новый контур complex-plan run с собственным хранилищем артефактов. +- `pcp-mission-planing-service` владеет миссиями, планами, режимами и связью режимов со слотами. +- `pcp-tgu-service` владеет tracked-plan состоянием, статусами планов и процессом запуска `createSatelliteMission`. +- `pcp-ui-service` не владеет доменными данными, а выступает UI/BFF и синхронно вызывает backend-сервисы. + +Самые важные интеграционные потоки: + +1. TLE flow: `tle-monitoring-service` загружает TLE и публикует событие в `pcp.tle`; `pcp-ballistics-service` потребляет его и пересчитывает орбитальные данные. +2. Initial conditions flow: `slots-service` публикует initial conditions в тот же topic `pcp.tle`; `pcp-ballistics-service` потребляет событие другого типа и строит производные данные. +3. Route passport flow: `pcp-route-processing-service` читает `pcp.request.survey-georeference.v1`, публикует `pcp.route.in.v1` и `pcp.route.georeference.v1`; `pcp-request-service` обновляет покрытие заявок, `pcp-mission-planing-service` обновляет статус режима. +4. Mission/slot status flow: `pcp-mission-planing-service` подтверждает миссию и публикует `BookedSlotsStatusChangedEvent` в `pcp.slots.status.v1`; `slots-service` обновляет статусы booked slots. +5. Complex mission flow: `pcp-complex-mission-service` синхронно оркестрирует request/catalog/ballistics/slots/coverage и сохраняет modes/snapshots/run. +6. New complex-plan flow: `pcp-dynamic-plan-service` синхронно забирает заявки и баллистические данные, сохраняет run artifacts, intervals и MPL candidates. +7. TGU/Camunda flow: `pcp-tgu-service` строит план по окнам видимости, запускает BPMN process `createSatelliteMission`, хранит tracked plan и подтверждает его через worker `updatePlanStatus`. + +Ключевые риски: + +- в системе есть два похожих контура complex planning: `pcp-complex-mission-service` и `pcp-dynamic-plan-service`, у обоих есть сущности `complex_plan_run*`; без явного контракта владения это риск split-brain и дублирования логики; +- topic `pcp.tle` используется для разных типов событий: TLE update и initial conditions/RVA; это требует фильтров и повышает риск ошибочной обработки; +- явного transactional outbox/idempotency слоя почти не видно; часть Kafka consumers имеет частичные проверки дедупликации, но общий at-least-once дизайн не оформлен; +- `pcp-dynamic-plan-service` имеет Gradle-зависимость от `:services:pcp-request-service`, что создает compile-time coupling между сервисами; +- `pcp-ui-service` и orchestration-сервисы строят длинные синхронные HTTP/WebClient цепочки; +- Camunda workers в `pcp-mission-planing-service` ловят исключения и логируют их без явного `fail`/`throw` после catch; это может приводить к подвисанию job, если ошибка случилась до complete; +- именование части модулей и пакетов содержит опечатки (`srpring`, `planing`) и исторические package names, что усложняет навигацию и эксплуатацию. + +## 2. Services overview table + +| Service | Main responsibility | Main inputs | Main outputs | Storage | External dependencies | Notes | +|--------|---------------------|-------------|--------------|---------|------------------------|-------| +| `spring-cloud-config-server` | Централизованная выдача конфигураций | HTTP Config Server requests от клиентов | YAML config для сервисов | Git/config repo | `config-repo` Git URI | Инфраструктурный сервис | +| `pcp-srpring-boot-admin-server` | Spring Boot Admin UI/monitoring | Actuator/admin registrations | Admin UI/status | Нет доменного storage | Spring Boot Admin clients | Название модуля содержит опечатку `srpring` | +| `pcp-ui-service` | UI/BFF для карты, каталога, заявок, слотов, миссий | Browser HTTP, UI REST endpoints | HTML views, proxy calls | Нет | request, catalog, ballistics, stations, slots, mission, complex-mission, coverage-scheme | Tight BFF coupling к backend API | +| `pcp-request-service` | Заявки, сетка Земли, projection заявки на ячейки, route matching | REST `/api/v1/requests`, legacy `/api/requests`, `/api/earth-grid`; Kafka `pcp.request.in.v1`, `pcp.route.georeference.v1` | DB state, HTTP responses | PostgreSQL `pcp_requests` | Kafka, geometry libraries | Source-of-truth для заявок и Earth grid | +| `pcp-route-processing-service` | Преобразование route passport сообщений в route events | Kafka `pcp.request.survey-georeference.v1`; test REST | Kafka `pcp.route.in.v1`, `pcp.route.georeference.v1` | Нет | Kafka | Stateless routing/normalization service | +| `pcp-satellite-catalog-service` | Каталог спутников, групп и профилей | REST `/api/satellites`, `/api/satellite-groups` | DB state, HTTP responses | PostgreSQL `pcp_satellite_catalog` | Нет явных downstream API | Source-of-truth для satellite metadata | +| `pcp-stations-service` | Каталог наземных станций | REST `api/stations` | DB state, HTTP responses | PostgreSQL `pcp_stations` | Нет явных downstream API | Малый CRUD-сервис | +| `tle-monitoring-service` | Загрузка, хранение и публикация TLE | REST `v1/api/tle`, scheduled CelesTrak load | Kafka `pcp.tle` | PostgreSQL `pcp_tle` | CelesTrak, satellite catalog/complex-mission config | Source-of-truth для исходных TLE | +| `pcp-ballistics-service` | Баллистические расчеты и хранение производных орбитальных данных | REST `/api/satellites`, `/api/tle`, `/api/rva`, `/api/obj-view`; Kafka `pcp.tle` | DB state, HTTP responses | PostgreSQL `pcp_ballistics` | Kafka, ballistics-lib | Source-of-truth для derived ballistics artifacts | +| `slots-service` | Слоты, покрытия, бронирования, initial conditions | REST `/api/slots`, `/api/slots/booking`, `/api/satellite`, `/api/satellite-ic`; Kafka `pcp.slots.status.v1` | DB state, Kafka `pcp.tle` | PostgreSQL `pcp_slots` | request-service, satellite-catalog, Kafka | Source-of-truth для slots/bookings | +| `pcp-complex-mission-service` | Оркестрация комплексной миссии и snapshots спутниковых режимов | REST `/api/com-plan`, `/api/satellites` | DB state, HTTP calls to downstream services | PostgreSQL `pcp_satellites` | request, catalog, ballistics, slots, coverage-scheme, stations | пересекается с dynamic-plan-service | +| `pcp-dynamic-plan-service` | Новый/отдельный engine complex-plan runs и persisted artifacts | REST `/api/v1/complex-plan/runs` | DB state, HTTP calls | PostgreSQL `pcp_complex_plan` | request-service, ballistics, satellite-catalog | Имеет compile-time Gradle dependency на request-service | +| `pcp-coverage-scheme-service` | Stateless расчет coverage scheme | REST `/api/coverage-schemes/calculate` | HTTP response | Нет | ballistics, complex-mission/satellite data | Алгоритмический stateless сервис | +| `pcp-mission-planing-service` | Миссии, планы, режимы съемки/сброса, подтверждение миссий | REST `/api/missions`; Kafka `pcp.route.in.v1`; Camunda jobs | DB state, Kafka `pcp.slots.status.v1`, Camunda complete/message | PostgreSQL `pcp_missions` | complex-mission, ballistics, stations, Kafka, Camunda | Название содержит `planing`; worker behavior требует внимания | +| `pcp-tgu-service` | ТГУ-планы, external points, tracked plans, запуск Camunda process | REST `/api/plans`, `/api/satellite-missions`, `/api/plan-confirmations`, `/api/platforms`; Kafka `pcp.tgu.kpp-update.v1`; scheduled jobs; Camunda job | DB state, Camunda process instance/complete | PostgreSQL `pcp_tgu` | classifier API, ballistics/external API, Kafka, Camunda | Связан с процессом `createSatelliteMission` | +| `pcp-complex-mission-flow` | Прототип/экспериментальный flow controller | REST FlowController | HTTP calls | Нет явного DB | complex-mission, earth-grid | Модуль закомментирован в `settings.gradle.kts`; вероятно не активен | + +## 3. Service-by-service analysis + +### 3.1 `spring-cloud-config-server` + +#### Purpose + +Инфраструктурный Config Server, отдающий конфигурации из `config-repo` остальным сервисам. + +#### Responsibilities + +- поднимать Spring Cloud Config Server; +- выдавать YAML-конфигурации по имени приложения/profile; +- централизовать адреса сервисов, порты, PostgreSQL, Kafka, Camunda, topic names и feature settings. + +#### What this service does not own + +- Не владеет доменными данными. +- Не выполняет бизнес-логику PCP. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| HTTP | Spring Cloud Config endpoints | Config client request | `SpringCloudConfigServerApplication` | Отдать конфигурацию сервиса | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| HTTP response | Service clients | YAML/properties | Spring Cloud Config | Runtime configuration | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `config-repo/*.yaml` | Конфигурации сервисов | `server.port`, datasource, Kafka, services URLs | Developers/ops | Config Server, services | Git/config storage, не доменная БД | + +#### Main business flows + +Не участвует в бизнес-флоу, только в bootstrap/config flow. + +#### Error handling and reliability + +Spring Cloud Config стандартно зависит от доступности `config-repo`/Git URI. Доменной retry/idempotency логики нет. + +#### Configuration + +- `config-repo/spring-cloud-config-server.yaml`. +- Local port в общем `application-local.yaml`: `38888`. +- Default server port в сервисной конфигурации: `${SERVER_PORT:8888}`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `services/spring-cloud-config-server/...Application.kt` | Boot application / Config Server | +| `config-repo/*.yaml` | Централизованные настройки всех сервисов | + +#### Open questions / unclear parts + +- Unknown: фактическая prod Git URI/credentials не анализировались за пределами config files. + +--- + +### 3.2 `pcp-srpring-boot-admin-server` + +#### Purpose + +Spring Boot Admin сервер для наблюдения за приложениями через actuator/admin client. + +#### Responsibilities + +- принимать регистрации admin clients; +- показывать статус приложений и actuator metadata. + +#### What this service does not own + +- Не владеет доменной моделью PCP. +- Не является API gateway. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| HTTP | Spring Boot Admin UI/API | Admin registration/status | `PcpSrpringBootAdminServerApplication` | Monitoring UI | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| HTTP | Browser/admin clients | Admin UI/status | Spring Boot Admin | Observability | + +#### Data ownership and storage + +Не обнаружено доменной БД. + +#### Configuration + +- `config-repo/pcp-srpring-boot-admin-server.yaml`. +- Local/default admin port в общем config: `7000` / `${pcp.ports.admin:38889}`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `services/pcp-srpring-boot-admin-server/...Application.kt` | Boot Admin application | + +#### Open questions / unclear parts + +- В названии модуля есть опечатка `srpring`; в Helm директории используется `pcp-spring-boot-admin-server`. Это может быть эксплуатационной неоднозначностью. + +--- + +### 3.3 `pcp-ui-service` + +#### Purpose + +UI/BFF сервис для пользовательского интерфейса PCP. Он отдает HTML views и предоставляет proxy/API endpoints к backend-сервисам. + +#### Responsibilities + +- страницы карты, каталога, RVA, complex-plan и станций; +- проксирование/агрегация данных заявок, сетки, слотов, спутников, станций, миссий и coverage scheme; +- фасад для frontend-операций без собственного доменного хранения. + +#### What this service does not own + +- Не владеет заявками, слотами, спутниками, станциями, миссиями или баллистическими данными. +- Не должен содержать source-of-truth бизнес-правила; его роль — UI/BFF. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| HTTP page | `/map`, `/groups`, `/satellites`, `/rva`, `/com-plan` | Browser request | UI controllers | Отдать UI pages | +| HTTP API/proxy | `/api/catalog/**` | catalog DTOs | catalog controllers/adapters | CRUD/просмотр каталога через UI | +| HTTP API/proxy | `/api/stations` | station DTOs | station controllers/adapters | Работа со станциями | +| HTTP API/proxy | `/api/requests/**`, `/requests/**` | request/grid DTOs | request/map controllers | Работа с заявками и визуализацией | +| HTTP API/proxy | `/api/slots/**`, `/api/satellite/**`, `/api/satellite-ic/**` | slot/satellite IC DTOs | slot/map controllers | Работа со слотами и IC | +| HTTP API/proxy | `/api/com-plan/**` | complex plan request/response | complex plan controllers | Запуск/просмотр complex mission plan | +| HTTP API/proxy | coverage-scheme endpoints | coverage DTOs | coverage adapter | Расчет покрытия для UI | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| HTTP/WebClient | `pcp-request-service` | request/grid DTOs | `EarthService`, request adapters | Заявки, сетка, request visualization | +| HTTP/WebClient | `pcp-satellite-catalog-service` | satellite/group/profile DTOs | `SatelliteCatalogService` | Каталог спутников | +| HTTP/WebClient | `pcp-ballistics-service` | orbit/flight-line/asc-node/RVA DTOs | `BallisticsService` | Баллистические данные для UI | +| HTTP/WebClient | `pcp-stations-service` | station DTOs | `StationService` | Станции | +| HTTP/WebClient | `slots-service` | slots/bookings/IC DTOs | `SlotService` | Слоты и бронирования | +| HTTP/WebClient | `pcp-mission-planing-service` | mission/mode DTOs | `MissionService` | Режимы и миссии | +| HTTP/WebClient | `pcp-complex-mission-service` | complex plan DTOs | `ComplexMissionService` | Complex mission views/actions | +| HTTP/WebClient | `pcp-coverage-scheme-service` | coverage scheme DTOs | `CoverageSchemeService` | Расчет покрытия | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| Нет доменных таблиц | UI/BFF stateless | — | — | — | Storage не обнаружен | + +#### Main business flows + +- UI action → BFF controller → WebClient call к backend сервису → response в UI. +- Complex-plan UI → `pcp-complex-mission-service`/`pcp-dynamic-plan-service` depending on endpoint usage. +- Map/request UI → `pcp-request-service`, `slots-service`, `pcp-ballistics-service`. + +#### Error handling and reliability + +- Основной механизм — синхронные HTTP/WebClient вызовы. +- Unknown: единая политика timeout/retry/fallback по всем BFF adapters не прослежена как отдельный инфраструктурный слой. + +#### Configuration + +- `config-repo/pcp-ui-service.yaml`. +- Local port: `7008`. +- Downstream URLs: request/earth-grid, complex-mission, satellite-catalog, ballistics, stations, slots, mission, coverage-scheme. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `services/pcp-ui-service/.../controller/*Controller.kt` | UI pages and proxy endpoints | +| `services/pcp-ui-service/.../service/*Service.kt` | WebClient adapters к backend services | +| `config-repo/pcp-ui-service.yaml` | URLs downstream-сервисов | + +#### Open questions / unclear parts + +- Unknown: является ли UI единственным внешним API для пользователей или есть прямые клиенты backend-сервисов. +- Risk: BFF напрямую знает много backend endpoints; изменения контрактов backend ломают UI без промежуточного API boundary. + +--- + +### 3.4 `pcp-request-service` + +#### Purpose + +Source-of-truth сервис для заявок на съемку/наблюдение, их геометрий, projection на Earth grid и обработки route matching. + +#### Responsibilities + +- CRUD заявок через новый `/api/v1/requests` и legacy `/api/requests` API; +- хранение исходной и остаточной геометрии заявки; +- хранение статуса заявки и процента покрытия; +- хранение Earth grid и связей заявка ↔ ячейка; +- прием заявок из Kafka `pcp.request.in.v1`; +- прием route georeference events из Kafka `pcp.route.georeference.v1`; +- расчет пересечения маршрута с заявками и обновление покрытия/остаточной геометрии; +- cleanup старых/закрытых заявок по расписанию. + +#### What this service does not own + +- Не владеет спутниками и их профилями — это `pcp-satellite-catalog-service`. +- Не владеет слотами и бронированиями — это `slots-service`. +- Не владеет баллистическими расчетами — это `pcp-ballistics-service`. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `/api/v1/requests` `POST/GET` | `RequestDto` / filters | `RequestController` | Новый API заявок | +| REST legacy | `/api/requests/**` | legacy request DTOs/params | `LegacyRequestsCompatController` | Совместимость со старым UI/API | +| REST | `/api/earth-grid/**` | grid filters | `EarthGridCompatController` | Работа с Earth grid и cells | +| REST test/import | `/api/v1/test/requests/import` | import payload | `TestRequestImportController` | Тестовый/import entry point | +| Kafka consumer | `pcp.request.in.v1` | `RequestDto` | `RequestKafkaListener.consumeRequest` | Создать/обновить заявку из message broker | +| Kafka consumer | `pcp.route.georeference.v1` | `KafkaMessage` | `RequestKafkaListener.consumeRoute` | Обновить покрытие заявок по маршруту | +| Scheduler | cleanup cron `0 0 3 * * *` | none | `RequestCleanupScheduler` / `RequestCleanupService` | Очистка/закрытие устаревших данных | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_requests.requests` | `RequestEntity` | `RequestService` | Хранение заявки | +| DB write | `pcp_requests.request_cells` | request-cell projection | `RequestGridProjectionService` | Индексация заявки по сетке | +| DB write | `pcp_requests.request_route_matches` | route match | `RouteMatchingService` | Дедупликация/история покрытия маршрутом | +| HTTP response | UI/clients/other services | request/grid DTOs | controllers | Выдача заявок, геометрий, сетки | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `requests` | Source-of-truth заявки | `id`, `name`, `selection_start/stop`, `max_roll_angle`, `required_coverage_percent`, `importance`, `app_type`, `resolution`, `original_geometry`, `remaining_geometry`, `status`, `source`, `match_count`, `last_matched_at`, `completed_at`, `version` | REST/Kafka request flow, route matching | UI, slots, complex-plan, complex-mission | Главная доменная таблица сервиса | +| `request_cells` | Projection заявки на Earth grid cells | `request_id`, `cell_id`, coverage fields | `RequestGridProjectionService` | Earth grid/read APIs, downstream flows | Projection/read model, не самостоятельный source-of-truth | +| `earth_cells` | Earth grid cells | cell number/geometry/importance | Grid init/import | request/slots/complex-mission | Source-of-truth grid внутри request-service | +| `earth_grid_settings` | Настройки сетки | grid params | service/config/init | service | Техническая настройка grid | +| `request_route_matches` | История покрытия заявки маршрутом | `request_id`, `route_id`, unique `(request_id, route_id)` | `RouteMatchingService` | service | Используется для дедупликации route processing | + +#### Main business flows + +1. Создание заявки через REST/Kafka. +2. Валидация DTO и геометрии. +3. Сохранение `requests`. +4. Перестройка projection в `request_cells`. +5. При получении маршрута: проверка `route_id`, геометрическое пересечение с заявками, обновление `remaining_geometry`, `match_count`, `status`, `last_matched_at/completed_at`. +6. Выдача данных UI, slots, complex-plan и complex-mission. + +#### Error handling and reliability + +- Для route matching есть явный DB-level uniqueness `(request_id, route_id)` и проверка повторной обработки. +- Kafka listeners валидируют payload и могут выбрасывать исключения при неверном сообщении. +- Transaction boundaries видны на сервисном уровне, но полного outbox/event sourcing слоя не обнаружено. +- DLQ/retention/retry policy в коде явно не описаны. + +#### Configuration + +- `config-repo/pcp-request-service.yaml`. +- DB: `pcp_requests`. +- Kafka group: `pcp-request-service`. +- Topics: `pcp.request.in.v1`, `pcp.route.georeference.v1`. +- Local port: `7005`; dev обычно `8080` через service deployment. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `RequestController.kt` | Новый REST API заявок | +| `LegacyRequestsCompatController.kt` | Legacy API compatibility | +| `EarthGridCompatController.kt` | Earth grid API | +| `RequestKafkaListener.kt` | Kafka входы request/route | +| `RequestService.kt` | CRUD и доменная логика заявок | +| `RequestGridProjectionService.kt` | Projection заявки на grid cells | +| `RouteMatchingService.kt` | Обработка маршрута и покрытие заявок | +| `RequestCleanupService.kt` | Scheduled cleanup | +| `src/main/resources/db/migration/*` | Схема `requests`, `earth_cells`, `request_cells`, `request_route_matches` | + +#### Open questions / unclear parts + +- Unknown: какой внешний producer отвечает за `pcp.request.in.v1`. +- Unknown: политика Kafka retry/DLQ задается инфраструктурно или отсутствует. + +--- + +### 3.5 `pcp-route-processing-service` + +#### Purpose + +Stateless сервис нормализации route passport сообщений: читает входной Kafka topic, парсит JSON и публикует два производных события для request-service и mission-planning. + +#### Responsibilities + +- потреблять `pcp.request.survey-georeference.v1`; +- парсить route passport JSON в `RoutePassportDto`; +- публиковать normalized route event в `pcp.route.in.v1`; +- публиковать georeference route event в `pcp.route.georeference.v1`; +- сохранять trace/correlation metadata в wrapper/header, если оно есть во входном сообщении. + +#### What this service does not own + +- Не хранит заявки, маршруты или статусы режимов. +- Не решает, какие заявки покрыты маршрутом; это делает `pcp-request-service`. +- Не решает бизнес-статус режима; это делает `pcp-mission-planing-service`. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| Kafka consumer | `pcp.request.survey-georeference.v1` | raw JSON / parsed route passport | `RoutePassportMessageListener` | Принять route passport | +| REST test | `/api/route-processing/test`, `/api/route-processing/test/send-passport-message` | test request | test controller | Локальная/ручная проверка | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| Kafka producer | `pcp.route.in.v1` | `KafkaMessage`, type `ModeStatusChangedEvent` | `RoutePassportKafkaPublisherService` | Событие для mission planning | +| Kafka producer | `pcp.route.georeference.v1` | `KafkaMessage`, type `RouteGeoRefEvent` | `RoutePassportKafkaPublisherService` | Событие для request route matching | + +#### Data ownership and storage + +Нет доменных таблиц; сервис stateless. + +#### Main business flows + +1. Получить сообщение из `pcp.request.survey-georeference.v1`. +2. Распарсить route passport. +3. Завернуть в общий `KafkaMessage`. +4. Положить type header/event type. +5. Отправить в два downstream topics. + +#### Error handling and reliability + +- Ошибки парсинга приводят к exception в consumer path. +- Явного DLQ/outbox/idempotency слоя не обнаружено. +- Сервис не хранит offset-level бизнес-дедупликацию. + +#### Configuration + +- `config-repo/pcp-route-processing-service.yaml`. +- Input topic: `pcp.request.survey-georeference.v1`. +- Output topics: `pcp.route.in.v1`, `pcp.route.georeference.v1`. +- Kafka group: `pcp-route-processing-service`. +- Local port: `7004`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `RoutePassportMessageListener.kt` | Kafka consumer | +| `RoutePassportJsonParserService.kt` | Parsing/normalization | +| `RoutePassportKafkaPublisherService.kt` | Kafka producer | +| `RoutePassportTestController.kt` | Test endpoint | + +#### Open questions / unclear parts + +- Unknown: внешний источник topic `pcp.request.survey-georeference.v1`. +- Risk: route event публикуется синхронно в два topic без собственного persisted outbox; при частичной ошибке возможна рассинхронизация downstream topics. + +--- + +### 3.6 `pcp-satellite-catalog-service` + +#### Purpose + +Source-of-truth сервис каталога спутников, групп спутников и профилей наблюдения/слотов. + +#### Responsibilities + +- CRUD спутников; +- поиск спутника по NORAD; +- batch загрузка/обновление спутников; +- CRUD групп спутников; +- хранение observation profile и slot profile; +- хранение углов слота/профиля. + +#### What this service does not own + +- Не владеет TLE — это `tle-monitoring-service`. +- Не владеет вычисленными орбитальными данными — это `pcp-ballistics-service`. +- Не владеет слотами и бронированиями — это `slots-service`. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `/api/satellites` CRUD | satellite DTOs | `SatelliteCatalogController` | Управление спутниками | +| REST | `/api/satellites/by-norad` | NORAD id | `SatelliteCatalogController` | Поиск спутника | +| REST | `/api/satellites/{id}/observation-profile` | profile DTO | `SatelliteCatalogController` | Профиль наблюдения | +| REST | `/api/satellites/{id}/slot-profile` | slot profile DTO | `SatelliteCatalogController` | Профиль слотов | +| REST | `/api/satellites/batch` | batch DTO | `SatelliteCatalogController` | Batch operation | +| REST | `/api/satellite-groups` CRUD | group DTOs | `SatelliteGroupController` | Группы спутников | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_satellite_catalog.*` | satellite/profile/group entities | catalog services | Persist catalog state | +| HTTP response | UI, slots, complex-mission, TLE monitoring, TGU indirectly | satellite/profile/group DTOs | controllers | Выдача справочника | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `satellite` | Source-of-truth спутника | id, NORAD, name/status/platform fields | catalog API | UI, slots, complex-mission, TLE monitoring | Основная таблица каталога | +| `satellite_group` | Группы спутников | id, name | catalog API | UI/clients | Справочник групп | +| `satellite_group_satellite` | Связь group-satellite | group id, satellite id | catalog API | UI/clients | Link table | +| `satellite_observation_profile` | Профиль наблюдения | satellite id, observation params | catalog API | planning/complex flows | Domain config для расчетов | +| `satellite_slot_profile` | Профиль слотов | satellite id, slot params | catalog API | slots/complex flows | Domain config для slot calculation | +| `satellite_slot_angle` | Углы слота | profile id, angle values | catalog API | slots/complex flows | Detail table | + +#### Main business flows + +- UI/clients создают или редактируют спутник. +- Downstream services запрашивают каталог и профили при расчетах слотов, complex mission и TLE updates. + +#### Error handling and reliability + +- Стандартный CRUD/JPA flow. +- Явной messaging/idempotency логики не обнаружено. + +#### Configuration + +- `config-repo/pcp-satellite-catalog-service.yaml`. +- DB: `pcp_satellite_catalog`. +- Local port: `7013`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `SatelliteCatalogController.kt` | Satellite/profile REST API | +| `SatelliteGroupController.kt` | Group REST API | +| `SatelliteService.kt`, profile services | Business CRUD | +| `entity/*` | JPA mapping | +| `db/migration/*` | Schema/FK for catalog | + +#### Open questions / unclear parts + +- Unknown: является ли NORAD уникальным бизнес-ключом на уровне БД или только на уровне сервисной логики. + +--- + +### 3.7 `pcp-stations-service` + +#### Purpose + +Source-of-truth CRUD-сервис наземных станций. + +#### Responsibilities + +- хранить станции; +- отдавать список/станцию по id; +- создавать/удалять станцию. + +#### What this service does not own + +- Не владеет баллистикой видимости станций; расчеты делаются в ballistics/coverage flows. +- Не владеет mission plans. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `api/stations` `GET/POST/DELETE` | station DTO | `StationController` | CRUD станций | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_stations.stations` | `StationEntity` | `StationService` | Persist station catalog | +| HTTP response | UI/mission/clients | station DTO | `StationController` | Выдача станций | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `stations` | Source-of-truth станции | number/name, longitude, latitude, height, elevation | station API | UI, mission/coverage flows | Малый справочник | + +#### Main business flows + +CRUD справочника станций. + +#### Error handling and reliability + +Стандартный REST/JPA flow; messaging не обнаружен. + +#### Configuration + +- `config-repo/pcp-stations-service.yaml`. +- DB: `pcp_stations`. +- Local port: `7009`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `StationController.kt` | REST API | +| `StationService.kt` | CRUD logic | +| `StationEntity.kt` | JPA entity | +| `db/migration/*` | Stations schema | + +#### Open questions / unclear parts + +- Unknown: кто является владельцем master-data станций вне PCP, если такой внешний источник есть. + +--- + +### 3.8 `tle-monitoring-service` + +#### Purpose + +Сервис загрузки, хранения и публикации TLE. Он следит за TLE, подтягивает их из CelesTrak и рассылает обновления downstream-сервисам через Kafka. + +#### Responsibilities + +- хранить TLE records в `pcp_tle`; +- загружать TLE вручную через REST; +- периодически проверять/обновлять TLE; +- получать список спутников из catalog/complex service configuration; +- публиковать TLE update events в Kafka topic `pcp.tle`. + +#### What this service does not own + +- Не владеет каталогом спутников. +- Не рассчитывает орбиты/покрытия; это `pcp-ballistics-service`. +- Не владеет слотами или миссиями. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `v1/api/tle` `GET` | query/none | `TLEController` | Получить TLE | +| REST | `v1/api/tle/load-array` | TLE array DTO | `TLEController` | Ручная загрузка массива TLE | +| REST | `v1/api/tle/check-cycle` | none | `TLEController` | Ручной запуск проверки | +| REST | `v1/api/tle/from-celestrack` | NORAD/list params | `TLEController` | Загрузить из CelesTrak | +| Scheduler | fixed schedule, примерно раз в 3 часа | none | `CelesTrakService` | Периодическое обновление TLE | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| Kafka producer | `pcp.tle` | `KafkaMessage` with TLE DTO/event type | `KafkaProducer` / `CelesTrakService` | Сообщить ballistics о TLE update | +| DB write | `pcp_tle.tle` | `TLEEntity` | TLE/CelesTrak services | Сохранить TLE | +| HTTP/WebClient | satellite catalog / configured satellite service | satellite DTOs | `ComplexMissionService` naming in code | Получить список спутников для обновления TLE | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `tle` | Source-of-truth TLE records | satelliteId/NORAD, revolution, TLE lines, `time_rec` | TLE load/update flows | TLE API, Kafka publisher | Migration history содержит исправление кириллической `с` в `time_reс` → `time_rec` | + +#### Main business flows + +1. Получить список спутников. +2. Для каждого спутника проверить актуальность TLE. +3. Загрузить/обновить TLE из CelesTrak. +4. Сохранить TLE в DB. +5. Опубликовать событие в `pcp.tle`. +6. `pcp-ballistics-service` пересчитает производные орбитальные данные. + +#### Error handling and reliability + +- Scheduled load продолжает работу при ошибках отдельных операций, насколько видно по service-level try/catch/logging. +- Явного outbox для связки DB write + Kafka publish не обнаружено. +- Kafka topic shared с initial conditions событиями; корректность зависит от event type/filter downstream. + +#### Configuration + +- `config-repo/tle-monitoring-service.yaml`. +- DB: `pcp_tle`. +- Kafka default topic: `pcp.tle`. +- Kafka group: `pcp`. +- Service URLs: complex mission / satellite catalog settings. +- Local port: `7001`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `TLEController.kt` | REST API TLE | +| `CelesTrakService.kt` | External TLE load/update cycle | +| `KafkaProducer.kt` | Publishes TLE events | +| `TLEEntity.kt` | JPA entity | +| `db/migration/*` | TLE schema | + +#### Open questions / unclear parts + +- Unknown: точная политика versioning TLE events и какие event type values считаются публичным контрактом. +- Risk: DB update и Kafka publish не выглядят атомарными. + +--- + +### 3.9 `pcp-ballistics-service` + +#### Purpose + +Сервис баллистических расчетов и хранения производных орбитальных данных. Он принимает TLE/initial conditions, рассчитывает орбитальные точки, asc nodes, flight lines, покрытия, cell coverage и отдает расчетные данные другим сервисам. + +#### Responsibilities + +- потреблять TLE update events и IC/RVA events из Kafka topic `pcp.tle`; +- рассчитывать и хранить PDCM/орбитальные точки; +- рассчитывать asc nodes, flight lines, Earth coverage, cell covering; +- предоставлять API для orbit availability, flight line, RVA, MPL point/square, exact-time position; +- очищать расчетные данные по спутнику; +- парсить TLE и рассчитывать RVA по TLE через REST. + +#### What this service does not own + +- Не владеет исходным TLE как master data — это `tle-monitoring-service`. +- Не владеет каталогом спутников — это `pcp-satellite-catalog-service`. +- Не владеет бронированиями/слотами — это `slots-service`. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| Kafka consumer | `pcp.tle`, event type TLE update | `TLEDTO` inside `KafkaMessage` | `SatelliteService.listener2` | Пересчитать производные данные по TLE | +| Kafka consumer | `pcp.tle`, event type IC/RVA placed | `SatelliteICDTO` | `SatelliteIcRvKafkaListener` / `SatelliteIcEventService` | Обработать initial conditions | +| REST | `/api/satellites/{norad_id}/orbit` | time interval | `SatelliteController` | Orbit points | +| REST | `/api/satellites/orbit/availability` | time/id | `SatelliteController` | Доступность орбитальных данных | +| REST | `/api/satellites/{norad_id}/asc-node` | satellite id | `SatelliteController` | Asc nodes | +| REST | `/api/satellites/{norad_id}/flight-line` | interval | `SatelliteController` | Flight line | +| REST | `/api/satellites/{norad_id}/rva` | interval | `SatelliteController` | Radio visibility area | +| REST | `/api/satellites/{satellite_id}/extract-time` | `ExactTimePositionRequestDTO` | `SatelliteController` | Position at exact time | +| REST | `/api/obj-view/mpl-square`, `/api/obj-view/mpl-point` | `ObjViewRequestDTO` | `ObjViewController` | MPL view calculations | +| REST | `/api/obj-view/cell-coverage/{number}` | cell number | `ObjViewController` | Coverage by cell | +| REST | `/api/rva` | `IcRvaRequestDTO` | `RVAController` | RVA calculation | +| REST | `/api/tle/parse`, `/api/tle/rva` | TLE DTOs | `TLEController` | TLE parse/RVA | +| REST | `/api/satellites/{satellite_id}/clear` | satellite id | `SatelliteController` | Очистка расчетных данных | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_ballistics.pdcm` | PDCM/orbit point rows | `SatelliteService` | Store orbit points | +| DB write | `asc_node`, `flight_line`, `earth_coverage`, `cell_covering` | derived ballistics artifacts | `SatelliteService` | Store derived artifacts | +| HTTP response | complex-mission, complex-plan, coverage-scheme, UI, TGU | DTOs from `pcp-types-lib` | controllers | Расчетные данные downstream-сервисам | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `pdcm` | Орбитальные точки/состояния | satelliteId, time, revolution, coordinates/velocity fields | TLE/IC processing | ballistics API, complex-plan | Derived source-of-truth внутри ballistics | +| `asc_node` | Восходящие узлы | satelliteId, time/revolution | calculation flow | clients | Derived artifact | +| `flight_line` | Линия полета | satelliteId, time/geometry | calculation flow | complex-plan, UI, coverage | Derived artifact | +| `earth_coverage` | Покрытие Земли | satelliteId, interval/geometry | calculation flow | coverage/complex flows | Derived artifact | +| `cell_covering` | Покрытие grid cell | cell number, contour geometry | calculation flow | obj-view/coverage flows | Projection/derived data | + +#### Main business flows + +1. TLE update: + 1. Kafka message from `pcp.tle`. + 2. Filter by TLE event type. + 3. Parse `TLEDTO`. + 4. Calculate orbital products. + 5. Persist PDCM/asc node/flight line/coverage/cell covering. +2. Initial conditions update: + 1. Kafka message from `pcp.tle` with IC event type. + 2. Parse `SatelliteICDTO`. + 3. Normalize optional coefficients. + 4. Calculate/persist derived orbit data. +3. HTTP calculation: + 1. Downstream sends satellite/time/geometry request. + 2. Service reads persisted artifacts or computes from state. + 3. Returns DTO/geometry response. + +#### Error handling and reliability + +- Kafka listeners use filters by event type. +- Some calculations run asynchronously/coroutine-like; exact transactional boundary between all derived tables should be reviewed for partial updates. +- No explicit outbox because this service mainly consumes and stores. +- Idempotency by satellite/time/revolution is not fully evident from report-level scan; check repository constraints before relying on it. + +#### Configuration + +- `config-repo/pcp-ballistics-service.yaml`. +- DB: `pcp_ballistics`. +- Kafka default topic: `pcp.tle`. +- Kafka group: `pcp-ballistics-service`. +- Calculation interval configured in config. +- Local port: `7003`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `SatelliteController.kt` | Main ballistics REST API | +| `ObjViewController.kt` | MPL/cell coverage API | +| `RVAController.kt` | RVA API | +| `TLEController.kt` | TLE parse/RVA endpoints | +| `SatelliteService.kt` | Core calculation and Kafka TLE listener | +| `SatelliteIcRvKafkaListener.kt` | IC/RVA event listener | +| `SatelliteIcEventService.kt` | IC event handling | +| `entity/*` | PDCM, asc node, flight line, coverage entities | +| `db/migration/*` | Ballistics schema | + +#### Open questions / unclear parts + +- Unknown: exact consistency guarantees when recalculating multiple derived tables for one satellite. +- Risk: shared topic `pcp.tle` combines multiple event families. + +--- + +### 3.10 `slots-service` + +#### Purpose + +Source-of-truth сервис слотов, бронирований и satellite initial conditions. Он рассчитывает покрытия/слоты, хранит бронирования и реагирует на изменение статусов booked slots. + +#### Responsibilities + +- расчет слотов по полигону/заявке/интервалу; +- хранение слотов; +- бронирование и отмена слотов; +- хранение связи booked slot ↔ request; +- хранение/CRUD satellite IC; +- публикация initial conditions в Kafka topic `pcp.tle`; +- потребление событий статуса booked slots из `pcp.slots.status.v1`; +- scheduler timeout для booked slots. + +#### What this service does not own + +- Не владеет заявками — читает их из `pcp-request-service`. +- Не владеет каталогом спутников — читает `pcp-satellite-catalog-service`. +- Не владеет баллистическими производными данными — публикует IC, но расчеты downstream делает `pcp-ballistics-service`. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `/api/slots/test-init` | init request | `SlotController` | Инициализация/тест slot data | +| REST | `/api/slots/poly-cover`, `/api/slots/poly-cover/batch` | polygon/time/satellite data | `SlotController` | Расчет покрытия полигонов слотами | +| REST | `/api/slots/request-cover` | request id/filter | `SlotController` | Покрытие заявки | +| REST | `/api/slots/interval` | interval params | `SlotController` | Слоты за интервал | +| REST | `/api/slots/booking/**` | booking DTOs | `BookingController` | CRUD/booking/cancel booked slots | +| REST | `/api/slots/mission/**` | mission/satellite request | `MissionController` | Slot data для mission planning | +| REST | `/api/satellite/**` | satellite/IC/RVA params | `SatelliteController` | RVA/IC helper endpoints | +| REST | `/api/satellite-ic/**` | IC DTO | `SatelliteIcController` | CRUD initial conditions | +| Kafka consumer | `pcp.slots.status.v1` | `BookedSlotsStatusChangedEvent` | `BookedSlotsKafkaListener` | Обновить статусы booked slots | +| Scheduler | timeout check | none | `BookedSlotTimeoutScheduler` | Mark timed-out bookings | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `slot`, `booked_slot`, `booked_slot_request`, `satellite_ic` | slot/booking/IC entities | slot/booking/IC services | Persist slots/bookings | +| Kafka producer | `pcp.tle` | `KafkaMessage` type `ICRVPlacedEvent` | `SatelliteIcKafkaPublisher` | Передать initial conditions в ballistics | +| HTTP/WebClient | `pcp-request-service` | earth grid/request DTOs | `EarthService` | Получить заявки/cells | +| HTTP/WebClient | `pcp-satellite-catalog-service` | satellite/profile DTOs | `SatelliteCatalogClient` | Получить параметры спутника | +| HTTP response | UI, complex-mission, mission-planning | slots/booking DTOs | controllers | Выдать расчет/состояние слотов | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `slot` | Source-of-truth slot candidate/window | satellite id, interval, geometry/coverage fields | slot calculation/init | booking/mission/UI | Основная сущность слота | +| `booked_slot` | Source-of-truth бронирования слота | `slot_id`, `cycle`, status, timings | booking API, Kafka status update | mission/UI/slots | Unique `(slot_id, cycle)` | +| `booked_slot_request` | Связь booking ↔ request | `booked_slot_id`, `request_id` | booking request flow | slots/mission flows | Unique `(booked_slot_id, request_id)` | +| `satellite_ic` | Initial conditions спутника | satellite id, IC/RVA fields | IC API | publisher, UI | Данные для отправки в ballistics | + +#### Main business flows + +1. Slot calculation: + 1. Получить request/grid/satellite profile. + 2. Рассчитать доступные слоты/покрытие. + 3. Вернуть результат или сохранить slot state. +2. Booking: + 1. Принять booking request. + 2. Найти slot/cycle. + 3. Создать `booked_slot` и связи с request. + 4. Вернуть booking state. +3. Mission confirmation status: + 1. Получить `BookedSlotsStatusChangedEvent` из Kafka. + 2. Найти booked slots. + 3. Обновить status. +4. Initial conditions: + 1. Создать/выбрать satellite IC. + 2. Опубликовать `ICRVPlacedEvent` в `pcp.tle`. + 3. Ballistics пересчитает орбитальные производные. + +#### Error handling and reliability + +- Есть DB uniqueness для booked slots и booked slot requests. +- Есть timeout scheduler для зависших бронирований. +- Kafka consumer обновляет статусы, но общего DLQ/outbox слоя не обнаружено. +- REST endpoint `/api/slots/booking/process` выглядит как незавершенный/legacy path: в inventory он возвращает `Nothing`; нужно перепроверить перед использованием. + +#### Configuration + +- `config-repo/slots-service.yaml`. +- DB: `pcp_slots`. +- Kafka default topic: `pcp.tle`. +- Status topic: `pcp.slots.status.v1`. +- Downstream URLs: request-service/earth-grid, satellite-catalog. +- Timeout/check settings for booked slots. +- Local port: `7006`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `SlotController.kt` | Slot calculation API | +| `BookingController.kt` | Booking API | +| `MissionController.kt` | Mission-related slot API | +| `SatelliteController.kt` | IC/RVA helper API | +| `SatelliteIcController.kt` | IC CRUD API | +| `SlotService.kt` | Slot calculation/booking logic | +| `BookingService.kt` / status services | Booking lifecycle | +| `BookedSlotsKafkaListener.kt` | Status event consumer | +| `SatelliteIcKafkaPublisher.kt` | IC event producer | +| `BookedSlotTimeoutScheduler.kt` | Timeout handling | +| `db/migration/*` | Slot/booking schema | + +#### Open questions / unclear parts + +- Unknown: precise lifecycle enum of booked slots should be reviewed when answering status transition questions. +- Risk: IC events are published into `pcp.tle`, a topic also used for TLE updates. + +--- + +### 3.11 `pcp-complex-mission-service` + +#### Purpose + +Оркестрационный и stateful сервис комплексной миссии. Он строит и хранит спутниковые режимы, snapshots и complex plan runs, вызывая request, satellite catalog, ballistics, slots и coverage-scheme сервисы. + +#### Responsibilities + +- запуск complex plan process через `/api/com-plan/process`; +- хранение complex plan run и run-satellite links; +- хранение satellite modes, cells, booked slots и snapshots; +- выдача режимов спутника, statistics, mission-for-paint и snapshots; +- подготовка/пересчет satellite modes; +- оркестрация расчетов через downstream services. + +#### What this service does not own + +- Не владеет каталогом спутников как master data. +- Не владеет заявками как master data. +- Не владеет слотами как source-of-truth booking state. +- Не владеет баллистическими вычислениями как source-of-truth. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `/api/com-plan/process` | `ComplexPlanProcessRequestDTO`, user headers | `ComplexPlanController` | Запустить complex plan run | +| REST | `/api/com-plan/runs`, `/runs/{id}`, `/runs/{id}/modes` | filters/id | `ComplexPlanController` | Читать run state | +| REST | `/api/com-plan/clear`, `DELETE /runs/{id}` | id/none | `ComplexPlanController` | Очистка run/state | +| REST | `/api/com-plan/cell-with-mars/{cell_number}` | cell number | `ComplexPlanController` | Данные ячейки с MARS/coverage context | +| REST | `/api/satellites` | filters | `SatelliteController` | Satellite modes overview | +| REST | `/api/satellites/{satellite_id}/mission*` | satellite id | `SatelliteController` | Mission/statistics/paint data | +| REST | `/api/satellites/{satellite_id}/modes` | interval, runId | `SatelliteController` | Modes by satellite/time | +| REST | snapshot endpoints | snapshot id/filters | `SatelliteController` | Snapshot read/write | +| REST | `/api/satellites/prepare` | prepare request | `SatelliteController` | Подготовка modes | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_satellites.satellite_modes*`, `complex_plan_run*` | mode/run/snapshot entities | complex mission services | Persist complex mission state | +| HTTP/WebClient | `pcp-ballistics-service` | MPL/cell/exact-time requests | `BallisticsService` | Баллистические расчеты | +| HTTP/WebClient | `pcp-request-service` | earth grid/request data | `EarthGridService` | Получить заявки/cells | +| HTTP/WebClient | `pcp-satellite-catalog-service` | satellite catalog/profile | `SatelliteCatalogClient` | Получить спутники | +| HTTP/WebClient | `slots-service` | slots/book/cancel requests | `SlotService`, `CoverageBatchClient` | Рассчитать/забронировать слоты | +| HTTP/WebClient | `pcp-coverage-scheme-service` | coverage scheme request | `CoverageSchemeCalculationClient` | Подбор coverage scheme | +| HTTP response | UI, mission planning, TLE monitoring? | modes/run DTOs | controllers | Выдача complex mission state | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `satellite_modes` | Режимы спутника | satelliteId, source, type, start/end, revolution, lat/lon, duration, contourWkt, roll, cellNum | complex mission calculation | UI, mission-planning | Stateful result of planning/calculation | +| `satellite_mode_cell` | Связь mode ↔ cell | mode id, cell id/number | calculation | UI/analytics | Projection/detail | +| `satellite_mode_booked_slot` | Связь mode ↔ booked slot | mode id, booked slot id | booking/planning flow | mission/complex | Дублирует ссылку на source-of-truth booking in slots-service | +| `satellite_mode_snapshots` | Snapshots modes | snapshot id, mode refs, metadata | snapshot flow | mission-planning | Used by mission planning `comPlanSnapshotId` | +| `complex_plan_run` | Run complex plan | id, status, requestedBy, params | `/process` flow | UI/clients | Пересекается по названию с new dynamic-plan-service | +| `complex_plan_run_satellite` | Run ↔ satellite | run id, satellite id | process flow | UI/clients | Link table | + +#### Main business flows + +1. Complex plan run: + 1. UI/API вызывает `/api/com-plan/process`. + 2. Сервис получает пользователя из headers. + 3. Загружает заявки/grid, спутники/profile, баллистические данные, слоты и coverage scheme. + 4. Рассчитывает modes/snapshots/run artifacts. + 5. Сохраняет state в `pcp_satellites`. + 6. Возвращает run response. +2. Satellite modes read: + 1. Клиент запрашивает modes/statistics/mission-for-paint. + 2. Сервис читает persisted modes/snapshots. + 3. Возвращает DTO для UI/mission planning. +3. Booking interaction: + 1. В ходе complex mission может вызывать slots-service для slot coverage/book/cancel. + 2. Локально сохраняет ссылки на booked slots, но source-of-truth статуса остается в `slots-service`. + +#### Error handling and reliability + +- Оркестрация строится на синхронных HTTP вызовах к нескольким сервисам. +- Компенсация при частично успешных calls видна не полностью; для book/cancel есть отдельные методы, но глобальная saga/transaction не оформлена как отдельный механизм. +- Нет явного outbox. + +#### Configuration + +- `config-repo/pcp-complex-mission-service.yaml`. +- DB: `pcp_satellites`. +- Service URLs: ballistics, request/earth-grid, complex-mission self, satellite-catalog, stations, slots, coverage-scheme. +- Local port: `7002`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `ComplexPlanController.kt` | Complex plan run REST API | +| `SatelliteController.kt` | Satellite modes/snapshots REST API | +| `ComplexPlanRunOrchestrationService.kt` | Run orchestration | +| `ComplexPlanCalculationService.kt` | Calculation core | +| `ComplexMissionService.kt` | Main service facade | +| `BallisticsService.kt`, `SlotService.kt`, `SatelliteCatalogClient.kt`, `CoverageSchemeCalculationClient.kt` | Downstream clients | +| `entity/*` | Modes/snapshots/run entities | +| `db/migration/*` | `pcp_satellites` schema | + +#### Open questions / unclear parts + +- Assumption: это исторически основной complex mission state сервис, а `pcp-dynamic-plan-service` — новый контур. Это следует из названий endpoints и комментариев, но явно в README не подтверждено. +- Risk: пересечение моделей `complex_plan_run` с `pcp-dynamic-plan-service`. + +--- + +### 3.12 `pcp-dynamic-plan-service` + +#### Purpose + +Новый/отдельный сервис ручного запуска и хранения complex-plan run artifacts. Он строит run, сохраняет выбранные заявки, интервалы, MPL candidates и результаты. + +#### Responsibilities + +- принимать ручной запуск complex-plan через `/api/v1/complex-plan/runs`; +- тестовый запуск `/test-default`; +- сохранять run state; +- сохранять satellite/request snapshots для run; +- строить intervals и MPL candidates; +- выдавать run, список runs, result summary и GeoJSON intervals; +- генерировать отчет по MPL candidates для тестового запуска. + +#### What this service does not own + +- Не владеет исходными заявками — читает из `pcp-request-service`. +- Не владеет каталогом спутников — читает из `pcp-satellite-catalog-service`. +- Не владеет баллистическими данными — читает из `pcp-ballistics-service`. +- Не видно, что он владеет финальным booking/mission execution lifecycle. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `POST /api/v1/complex-plan/runs` | `ComplexPlanRunStartRequestDTO`, user headers | `ComplexPlanRunController.startRun` | Создать manual run | +| REST | `POST /api/v1/complex-plan/runs/test-default` | headers | `ComplexPlanRunController.startDefaultTestRun` | Тестовый default run и report | +| REST | `GET /api/v1/complex-plan/runs/{id}` | id | `ComplexPlanRunController` | Получить run | +| REST | `GET /api/v1/complex-plan/runs` | filters | `ComplexPlanRunController` | Список runs | +| REST | `GET /api/v1/complex-plan/runs/{id}/result-summary` | id | `ComplexPlanRunController` | Summary artifacts | +| REST | `GET /api/v1/complex-plan/runs/{id}/intervals/geojson` | id | `ComplexPlanIntervalGeoJsonService` | GeoJSON persisted intervals | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_complex_plan.*` | run/request/interval/MPL/result entities | `ComplexPlanCalculator`, persistence services | Persist run artifacts | +| HTTP/WebClient | `pcp-request-service` | request interval query | `HttpRequestServiceClient` | Найти заявки в интервале | +| HTTP/WebClient | `pcp-ballistics-service` | flight line/MPL data | `HttpBallisticsServiceClient` | Получить баллистические данные | +| HTTP/WebClient | `pcp-satellite-catalog-service` | satellite data | service client | Получить спутник/profile | +| HTTP response | UI/API clients | run/report DTOs | controller/query/report services | Вернуть run state | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `complex_plan_run` | Source-of-truth run в новом сервисе | run id, status, requestedBy, interval, satellite/NORAD | `ComplexPlanCalculator` | API/query services | Не тот же storage, что `pcp-complex-mission-service` | +| `complex_plan_run_satellite` | Snapshot спутника для run | run id, satellite id/NORAD/profile fields | calculator | reports/API | Run-local snapshot | +| `complex_plan_run_request` | Snapshot selected request | run id, request id, geometry/status fields | calculator | algorithms/reports | Копия данных из request-service на момент run | +| `complex_plan_interval` | Persisted planning intervals | run id, interval start/end, geometry/context | interval builder | GeoJSON/report | Derived artifact | +| `complex_plan_mpl_candidate` | MPL candidates | run id, candidate geometry/time/score fields | MPL calculation service | report/result | Derived artifact | +| `complex_plan_result` | Run result | run id, summary/status | result service | API | Итоговый artifact | +| `complex_plan_result_request` | Result ↔ request detail | result id, request id | result service | API | Detail table | + +#### Main business flows + +1. Manual run: + 1. Получить request payload и user headers. + 2. Создать run со статусом running/created. + 3. Найти заявки, пересекающие интервал, через request-service. + 4. Загрузить flight line/MPL данные через ballistics-service. + 5. Построить интервалы и MPL candidates алгоритмами сервиса. + 6. Сохранить artifacts. + 7. Mark completed/failed. + 8. Вернуть persisted run response. +2. Read/report: + 1. Прочитать persisted artifacts. + 2. Сформировать summary/GeoJSON/report. + +#### Error handling and reliability + +- Сервис явно хранит run status и mark failed/completed paths. +- Внешние HTTP-вызовы синхронные. +- Нет Kafka/outbox. +- Risk: build file зависит от `project(":services:pcp-request-service")`, что нарушает обычную независимость сервисов. + +#### Configuration + +- `config-repo/pcp-dynamic-plan-service.yaml`. +- DB: `pcp_complex_plan`. +- URLs: request-service, ballistics-service, satellite-catalog-service. +- Local port: `7014`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `ComplexPlanRunController.kt` | REST API run lifecycle | +| `ComplexPlanCalculator.kt` | Main orchestration/calculation | +| `HttpRequestServiceClient.kt` | Request-service client | +| `HttpBallisticsServiceClient.kt` | Ballistics client | +| `IntervalsBuilder.kt` | Interval algorithm | +| `SpaceCraftFlightLineBuilder.kt` | Flight line preparation | +| `MplCandidateCalculationService.kt` | MPL candidate algorithm | +| `ComplexPlanRunQueryService.kt` | Query run state | +| `ComplexPlanIntervalGeoJsonService.kt` | GeoJSON report | +| `ComplexPlanMplCandidateReportService.kt` | MPL candidate report | +| `db/migration/*` | `pcp_complex_plan` schema | + +#### Open questions / unclear parts + +- Unknown: как этот новый сервис связан с `pcp-complex-mission-service` в production flow. +- Risk: два хранилища complex-plan run state могут расходиться. + +--- + +### 3.13 `pcp-coverage-scheme-service` + +#### Purpose + +Stateless алгоритмический сервис расчета coverage scheme. Он собирает данные из ballistics/complex mission и возвращает расчет, не сохраняя доменное состояние. + +#### Responsibilities + +- принять запрос на расчет coverage scheme; +- получить баллистические данные: MPL point/square, flight line, exact-time position; +- получить satellite/mode context из complex mission service; +- построить coverage scheme через внутренние алгоритмы; +- вернуть результат клиенту. + +#### What this service does not own + +- Не владеет persisted coverage state. +- Не владеет заявками, слотами, режимами или баллистическими данными. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `POST /api/coverage-schemes/calculate` | `CoverageSchemeCalculateRequestDTO` | `CoverageSchemeController` | Рассчитать coverage scheme | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| HTTP/WebClient | `pcp-ballistics-service` | ballistics requests | `BallisticsClient` | Получить расчетные данные | +| HTTP/WebClient | `pcp-complex-mission-service` | satellite data | `ComplexMissionService` client | Получить satellite context | +| HTTP response | complex-mission/UI/clients | coverage scheme DTO | `CoverageSchemeCalculationService` | Вернуть расчет | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| Нет доменных таблиц | Stateless calculation | — | — | — | Storage не обнаружен | + +#### Main business flows + +1. Получить coverage request. +2. Вызвать ballistics endpoints. +3. Получить satellite context. +4. Построить coverage scheme. +5. Вернуть результат без сохранения. + +#### Error handling and reliability + +- Синхронные HTTP calls, configured response timeout около 30s. +- Нет persistent retry/outbox, так как сервис stateless. + +#### Configuration + +- `config-repo/pcp-coverage-scheme-service.yaml`. +- URLs: ballistics, complex-mission, satellite-catalog. +- Response timeout and in-memory buffer settings. +- Local port: `7012`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `CoverageSchemeController.kt` | REST API | +| `CoverageSchemeCalculationService.kt` | Main calculation | +| `CoverageSchemeBuilderService.kt` | Algorithm builder | +| `BallisticsClient.kt` | Ballistics API client | +| `ComplexMissionService.kt` | Satellite context client | + +#### Open questions / unclear parts + +- Unknown: является ли результат coverage scheme всегда transient или где-то downstream сохраняется как part of complex mission state. + +--- + +### 3.14 `pcp-mission-planing-service` + +#### Purpose + +Stateful сервис управления миссиями, планами и режимами. Он генерирует survey/drop modes, связывает их со слотами и обновляет статусы по route events и confirmation flow. + +#### Responsibilities + +- CRUD миссий; +- хранение plans/modes/survey modes/drop modes; +- расчет survey modes по complex mission snapshot или satellite modes; +- расчет drop modes; +- подтверждение миссии и публикация статусов booked slots; +- обновление статуса survey mode по route passport event; +- выполнение Camunda job workers для процесса satellite plan. + +#### What this service does not own + +- Не владеет source-of-truth слотами и booked slots; он хранит связи/снимки для миссии, но статус должен синхронизироваться со `slots-service`. +- Не владеет complex mission modes/snapshots; читает их из `pcp-complex-mission-service`. +- Не владеет баллистическими/станционными справочниками. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `/api/missions` CRUD | mission DTOs | `MissionController` | Управление миссиями | +| REST | `/api/missions/{mission_id}/modes` | mission id | `MissionController` | Режимы миссии | +| REST | `/api/missions/modes/satellite` | satellite/time | `MissionController` | Режимы по спутнику/интервалу | +| REST | `/api/missions/{mission_id}/drops/calculate` | mission id | `MissionController` | Рассчитать drop modes | +| REST | `/api/missions/{mission_id}/surveys/calculate` | mission id, `comPlanSnapshotId` | `MissionController` | Рассчитать survey modes | +| REST | `/api/missions/{mission_id}/confirm` | mission id | `MissionController` | Подтвердить миссию | +| REST | `/api/missions/survey-modes/{mode_id}/status` | status update | `MissionController` | Ручное обновление status | +| Kafka consumer | `pcp.route.in.v1` | `ModeStatusChangedEvent` / `RoutePassportDto` | `ModeStatusChangedKafkaListener` | Обновить status survey mode по route event | +| Camunda job | `calculateSatelliteSurveyMissions` | variables: `satellitePlanId`, `spacecraftId`, `planStartTime`, `planEndTime` | `FlowService.surveyPlan` | Создать mission и survey modes | +| Camunda job | `calculateSatelliteDropMissions` | `satellitePlanId` | `FlowService.surveyDrop` | Рассчитать drop missions | +| Camunda job | `sendSatellitePlan` | `satellitePlanId` | `FlowService.sendPlan` | Имитация отправки плана и publish message accepted | +| Camunda job | `updateSlotsStatus` | `satellitePlanId` | `FlowService.confirmPlan` | Confirm mission и слоты | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_missions.*` | mission/plan/mode entities | `MissionPlaningService` | Persist mission state | +| Kafka producer | `pcp.slots.status.v1` | `BookedSlotsStatusChangedEvent` | `BookedSlotsEventPublisher` | Обновить status booked slots в slots-service | +| HTTP/WebClient | `pcp-complex-mission-service` | modes/snapshots DTOs | `ComplexMissionService` | Получить satellite modes/snapshots | +| HTTP/WebClient | `pcp-ballistics-service` | ballistics DTOs | `BallisticsService` | Расчетные данные | +| HTTP/WebClient | `pcp-stations-service` | station DTOs | stations client | Данные станций | +| Camunda complete/message | Camunda broker | variables: `modes`, `slots`, message `Message_SatellitePlanAccepted` | `FlowService` | Завершение BPM jobs | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `missions` | Source-of-truth миссии | mission id, satellite id, interval, status | REST/Camunda flow | UI/TGU/process | Root entity | +| `plans` | Планы внутри миссии | plan id, mission id, stage/snapshot refs | service | service/API | `stage4SnapshotId` links to complex mission snapshot | +| `modes` | Base mode | mode id, mission/plan refs, time/type | calculation | API/process | Base table for survey/drop modes | +| `survey_modes` | Survey mode detail | mode id, satelliteModeId, status, source | survey calculation, route events | API/process | Связь с complex mission satellite mode | +| `drop_modes` | Drop mode detail | mode id, drop params | drop calculation | API/process | Drop-specific state | +| `mode_booking` | Mode ↔ booked slot | survey mode id, booked slot id | confirm/calculation | service | Ссылка на slot-service booking | +| `drop_mode_survey` | Drop ↔ survey relation | drop mode id, survey mode id | drop calculation | service | Link table | + +#### Main business flows + +1. Survey calculation: + 1. Создать/найти mission. + 2. Получить modes/snapshot из complex-mission. + 3. Сформировать survey modes. + 4. Сохранить modes и связи. +2. Drop calculation: + 1. Взять survey modes миссии. + 2. Рассчитать drop modes. + 3. Сохранить links. +3. Confirm mission: + 1. Найти связанные booked slots. + 2. Обновить локальное состояние миссии. + 3. Опубликовать `BookedSlotsStatusChangedEvent` в `pcp.slots.status.v1`. + 4. `slots-service` обновит source-of-truth статусы slots. +4. Route event status: + 1. Получить `pcp.route.in.v1`. + 2. Найти survey mode по route passport. + 3. Обновить status survey mode. +5. Camunda process: + 1. Worker получает job variables. + 2. Вызывает `MissionPlaningService`. + 3. Complete job с variables или publish message. + +#### Error handling and reliability + +- Kafka publisher sends status event; legacy HTTP status update в slots-service, по комментариям в коде, удален из main pipeline. +- Camunda workers используют `autoComplete=false`, но в `FlowService` exceptions перехватываются и логируются; после catch нет явного `fail`/`throw`. Это риск для надежности worker lifecycle. +- Явный DLQ/outbox для Kafka публикации не обнаружен. +- Transaction boundaries для сохранения mission state и Kafka publish требуют отдельной проверки перед эксплуатационными гарантиями. + +#### Configuration + +- `config-repo/pcp-mission-planing-service.yaml`. +- DB: `pcp_missions`. +- Kafka group: `pcp-mission-planing-service`. +- Kafka topics: `pcp.slots.status.v1`, `pcp.route.in.v1`. +- Camunda gRPC endpoint configured. +- Downstream URLs: ballistics, complex-mission, stations. +- Local port: `7010`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `MissionController.kt` | REST API missions | +| `MissionPlaningService.kt` | Core domain/application logic | +| `FlowService.kt` | Camunda workers | +| `ModeStatusChangedKafkaListener.kt` | Route event consumer | +| `BookedSlotsEventPublisher.kt` | Slot status event producer | +| `ComplexMissionService.kt`, `BallisticsService.kt` | Downstream clients | +| `entity/*` | Mission/plan/mode persistence | +| `db/migration/*` | Mission schema | + +#### Open questions / unclear parts + +- Unknown: complete BPMN process model was not rendered/analyzed visually; worker names and variables came from code. +- Risk: worker error handling should be fixed/verified before relying on Camunda retries. + +--- + +### 3.15 `pcp-tgu-service` + +#### Purpose + +Сервис ТГУ-планирования: хранит tracked plan state, получает окна видимости/точки КА, строит ближайший план, запускает Camunda process `createSatelliteMission`, подтверждает план через worker/status API и интегрируется с внешним классификатором платформ. + +#### Responsibilities + +- принимать/хранить spacecraft points messages; +- периодически обновлять external points из внешнего API; +- рассчитывать ближайший plan из observation/insertion windows; +- запускать Camunda BPMN process `createSatelliteMission`; +- хранить tracked plan и process instance key; +- подтверждать plan; +- отдавать планы и external points через REST; +- проверять существование платформы через classifier API. + +#### What this service does not own + +- Не владеет баллистическими орбитальными расчетами; external API base URL настроен на ballistics. +- Не владеет mission planning state `pcp_missions`; он запускает BPMN-процесс, который дальше вызывает mission-planning workers. +- Не владеет каталогом спутников PCP напрямую; использует external classifier/platform API. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | `/api/external-points/{satelliteId}` | satellite id, interval | `ExternalPointsController` | Получить external points | +| REST | `/api/plans`, `/api/plans/{spacecraftId}` | filters/id | `PlanController` | Получить планы | +| REST | `/api/plan-confirmations/{planId}`, `POST /api/plan-confirmations` | plan id/request | `PlanConfirmationController` | Подтвердить plan | +| REST | `/api/platforms` | classifier filters | `PlatformController` | Прокси к classifier platforms | +| REST | `/api/satellite-missions/{noradId}` | NORAD | `SatelliteMissionController` | Запустить process создания миссии | +| Kafka consumer | `pcp.tgu.kpp-update.v1` | `SpacecraftPointsMessage` | `SpacecraftPointsKafkaConsumer` | Обновить spacecraft points | +| Scheduler | `external-update-fixed-delay-ms` | none | `ExternalPointsUpdateScheduler` | Периодически загрузить points из external API | +| Scheduler | `notification-check-fixed-delay-ms` | none | `PlanProcessScheduler` | Запустить process для планов около старта | +| Camunda job | `updatePlanStatus` | variable `satellitePlanId` | `PlanStatusWorker` | Mark tracked plan confirmed | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| DB write | `pcp_tgu.tracked_plan`, `spacecraft_planning_state` | tracked/process state | `TrackedPlanService`, `SpacecraftPlanningStateService` | Persist TGU planning state | +| HTTP/WebClient | external classifier | platform classifier request | `PlatformService` / classifier client | Проверить platform exists | +| HTTP/WebClient | external API / ballistics URL | RVA/external points request | `ExternalPointsClient` | Получить точки/окна видимости | +| Camunda create instance | Camunda broker | process `createSatelliteMission`, variables | `SatelliteMissionProcessService` | Запустить satellite mission process | +| Camunda complete | Camunda broker | complete `updatePlanStatus` | `PlanStatusWorker` | Завершить worker job | +| HTTP response | UI/clients | plan/process DTOs | controllers | Выдать статус/план | + +#### Data ownership and storage + +| Entity/Table/Collection | Purpose | Key fields | Written by | Read by | Notes | +|-------------------------|---------|------------|------------|---------|-------| +| `tracked_plan` | Source-of-truth tracked plan in TGU | planId, spacecraftId, start/end, processInstanceKey, status, source | scheduler/manual process start, confirmation worker | TGU APIs/schedulers | Управляет TGU lifecycle и dedup/supersede | +| `spacecraft_planning_state` | Текущее planning state по КА | spacecraftId, points/state fields | Kafka/external update flow | plan calculation | State/projection из external points | +| In-memory/domain `Plan`, `SpacecraftPoints` | Расчетные domain objects | planId, spacecraftId, windows | service layer | service layer | Не JPA source-of-truth сами по себе | + +#### Main business flows + +1. External points update: + 1. Scheduler или Kafka получает `SpacecraftPointsMessage`. + 2. Сохраняет planning state. + 3. Пересчитывает доступные планы. +2. Manual/auto process start: + 1. Получить NORAD/plan. + 2. Проверить платформу во внешнем classifier. + 3. Найти ближайший plan по visibility/insertion points. + 4. Зарегистрировать tracked plan. + 5. Запустить Camunda process `createSatelliteMission` с variables: `spacecraftId`, `satellitePlanId`, `planStartTime`, `planEndTime`, `kppId`. + 6. Сохранить `processInstanceKey`. +3. Confirmation: + 1. REST или Camunda worker подтверждает `planId`. + 2. `tracked_plan` получает confirmed status. + +#### Error handling and reliability + +- `SatelliteMissionProcessService` блокирует повторный auto-start для уже confirmed plan и при manual start supersedes previous plan. +- `PlanProcessScheduler` ловит ошибки запуска по каждому plan и логирует их. +- `PlanStatusWorker` бросает exception при отсутствии `satellitePlanId`, затем complete через `.join()`. +- Kafka/outbox для points updates не обнаружен. + +#### Configuration + +- `config-repo/pcp-tgu-service.yaml`. +- DB: `pcp_tgu`. +- Kafka topic: `pcp.tgu.kpp-update.v1`. +- Camunda gRPC endpoint configured. +- Classifier URL: external NSI classifier. +- External API base URL configured through ballistics service URL. +- Local port: `7011`. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `SatelliteMissionProcessService.kt` | Starts Camunda process `createSatelliteMission` | +| `PlanStatusWorker.kt` | Camunda worker `updatePlanStatus` | +| `PlanProcessScheduler.kt` | Auto-start process for upcoming plans | +| `ExternalPointsUpdateScheduler.kt` | Periodic external points refresh | +| `SpacecraftPointsKafkaConsumer.kt` | Kafka points consumer | +| `PlanCalculationService.kt` | Plan calculation algorithm | +| `TrackedPlanService.kt` | Tracked plan lifecycle | +| `SpacecraftPointsService.kt` | Points/state persistence and recalculation | +| `controllers/*` | REST API | +| `entity/*`, `db/migration/*` | TGU persistence | + +#### Open questions / unclear parts + +- Unknown: exact semantic difference between TGU plan and `pcp-mission-planing-service` mission plan must be read from BPMN/process docs. +- Assumption: TGU запускает upstream BPMN process, который затем вызывает mission-planning workers. + +--- + +### 3.16 `pcp-complex-mission-flow` + +#### Purpose + +Похоже на прототип/альтернативный flow service для комплексной миссии. В `settings.gradle.kts` модуль закомментирован, поэтому он, вероятно, не входит в основную сборку. + +#### Responsibilities + +- FlowController для запуска flow; +- сервисы `ComplexMission`, `EarthGrid`, `Flow` для оркестрации; +- HTTP calls к complex mission / earth grid services. + +#### What this service does not own + +- Не владеет доменными данными. +- Не должен рассматриваться как production source-of-truth, пока не подтверждена его активность. + +#### Entry points + +| Type | Name / endpoint / topic / job type | Input model | Main handler/class | Purpose | +|------|------------------------------------|-------------|--------------------|---------| +| REST | Flow endpoints | flow request | `FlowController` | Запуск экспериментального flow | + +#### Outputs + +| Type | Target | Output model/event | Producer class | Purpose | +|------|--------|--------------------|----------------|---------| +| HTTP/WebClient | complex mission / earth grid | service DTOs | `ComplexMissionService`, `EarthGridService` | Оркестрация flow | + +#### Data ownership and storage + +Storage не обнаружен. + +#### Main business flows + +Unknown: модуль неактивен в root settings; анализировать его как production flow рискованно. + +#### Error handling and reliability + +Unknown. + +#### Configuration + +- В `config-repo/application-local.yaml` есть port `7007` для `complex-mission-flow`. +- В `settings.gradle.kts` include закомментирован. + +#### Important classes + +| Class/File | Role | +|-----------|------| +| `FlowController.kt` | REST entry | +| `FlowService.kt` | Flow orchestration | +| `ComplexMissionService.kt`, `EarthGridService.kt` | Downstream clients | + +#### Open questions / unclear parts + +- Нужно подтвердить, используется ли сервис в runtime/Helm/CI. В текущем архиве он выглядит неактивным. + +## 4. Cross-service interactions + +### 4.1 Interaction table + +| Source service | Target service/system | Mechanism | Data sent | Purpose | Sync/Async | +|---------------|------------------------|-----------|-----------|---------|------------| +| `pcp-ui-service` | `pcp-request-service` | HTTP/WebClient | request/grid DTOs | UI operations with requests and Earth grid | Sync | +| `pcp-ui-service` | `pcp-satellite-catalog-service` | HTTP/WebClient | catalog requests | UI catalog | Sync | +| `pcp-ui-service` | `pcp-ballistics-service` | HTTP/WebClient | satellite/time/geometry requests | UI orbit/RVA/flightline | Sync | +| `pcp-ui-service` | `slots-service` | HTTP/WebClient | slot/booking/IC requests | UI slot booking and IC | Sync | +| `pcp-ui-service` | `pcp-complex-mission-service` | HTTP/WebClient | complex plan requests | UI complex plan | Sync | +| `pcp-ui-service` | `pcp-coverage-scheme-service` | HTTP/WebClient | coverage requests | UI coverage calculation | Sync | +| `pcp-ui-service` | `pcp-stations-service` | HTTP/WebClient | station requests | UI stations | Sync | +| `tle-monitoring-service` | CelesTrak | HTTP | NORAD/TLE request | Load TLE | Sync | +| `tle-monitoring-service` | `pcp-satellite-catalog-service` / configured satellite service | HTTP | satellite list request | Determine satellites for TLE update | Sync | +| `tle-monitoring-service` | `pcp-ballistics-service` | Kafka `pcp.tle` | TLE event | Trigger ballistics recalculation | Async | +| `slots-service` | `pcp-request-service` | HTTP | request/grid lookup | Slot coverage calculation | Sync | +| `slots-service` | `pcp-satellite-catalog-service` | HTTP | satellite/profile lookup | Slot calculation | Sync | +| `slots-service` | `pcp-ballistics-service` | Kafka `pcp.tle` | `ICRVPlacedEvent` / IC DTO | Trigger recalculation from IC | Async | +| `pcp-route-processing-service` | `pcp-request-service` | Kafka `pcp.route.georeference.v1` | `RoutePassportDto` | Update request coverage | Async | +| `pcp-route-processing-service` | `pcp-mission-planing-service` | Kafka `pcp.route.in.v1` | `RoutePassportDto` / mode status event | Update survey mode status | Async | +| `pcp-mission-planing-service` | `slots-service` | Kafka `pcp.slots.status.v1` | `BookedSlotsStatusChangedEvent` | Update booked slot statuses | Async | +| `pcp-mission-planing-service` | `pcp-complex-mission-service` | HTTP | mission/snapshot/mode requests | Build survey/drop modes | Sync | +| `pcp-mission-planing-service` | `pcp-ballistics-service` | HTTP | satellite/time requests | Ballistics data for planning | Sync | +| `pcp-mission-planing-service` | `pcp-stations-service` | HTTP | station lookup | Mission planning context | Sync | +| `pcp-complex-mission-service` | `pcp-request-service` | HTTP | earth grid/request lookup | Complex plan inputs | Sync | +| `pcp-complex-mission-service` | `pcp-satellite-catalog-service` | HTTP | satellite/profile lookup | Complex plan inputs | Sync | +| `pcp-complex-mission-service` | `pcp-ballistics-service` | HTTP | MPL/exact-time/cell requests | Ballistics in complex plan | Sync | +| `pcp-complex-mission-service` | `slots-service` | HTTP | slots/book/cancel requests | Slot selection/booking | Sync | +| `pcp-complex-mission-service` | `pcp-coverage-scheme-service` | HTTP | coverage request | Coverage scheme calculation | Sync | +| `pcp-dynamic-plan-service` | `pcp-request-service` | HTTP | interval request query | Select requests for run | Sync | +| `pcp-dynamic-plan-service` | `pcp-ballistics-service` | HTTP | flight line/MPL requests | Build intervals/candidates | Sync | +| `pcp-dynamic-plan-service` | `pcp-satellite-catalog-service` | HTTP | satellite lookup | Satellite context | Sync | +| `pcp-coverage-scheme-service` | `pcp-ballistics-service` | HTTP | MPL/flightline/exact-time requests | Build coverage scheme | Sync | +| `pcp-coverage-scheme-service` | `pcp-complex-mission-service` | HTTP | satellite/mode request | Satellite context | Sync | +| `pcp-tgu-service` | external classifier | HTTP | platform filter | Validate platform/NORAD | Sync | +| `pcp-tgu-service` | external API / ballistics URL | HTTP | points/RVA requests | Load external spacecraft points | Sync | +| `pcp-tgu-service` | Camunda broker | gRPC | `createSatelliteMission` variables | Start BPMN process | Sync command / async process | +| Camunda broker | `pcp-tgu-service` | gRPC job | `updatePlanStatus` | Confirm tracked plan | Async worker | +| Camunda broker | `pcp-mission-planing-service` | gRPC jobs | satellite plan variables | Calculate/confirm mission | Async worker | + +### 4.2 Kafka/event map + +| Topic | Producer | Consumer(s) | Event model | Partition key if visible | Purpose | +|------|----------|-------------|-------------|--------------------------|---------| +| `pcp.tle` | `tle-monitoring-service` | `pcp-ballistics-service` | TLE update event with `TLEDTO` inside `KafkaMessage` | satellite id / Unknown exact | Trigger recalculation from TLE | +| `pcp.tle` | `slots-service` | `pcp-ballistics-service` | `ICRVPlacedEvent` / `SatelliteICDTO` inside `KafkaMessage` | satellite id | Trigger recalculation from initial conditions | +| `pcp.request.in.v1` | Unknown external producer | `pcp-request-service` | `RequestDto` | Unknown | Create/update request asynchronously | +| `pcp.request.survey-georeference.v1` | Unknown external producer | `pcp-route-processing-service` | raw route passport JSON | Unknown | Ingest route passport | +| `pcp.route.in.v1` | `pcp-route-processing-service` | `pcp-mission-planing-service` | `KafkaMessage`, type `ModeStatusChangedEvent` | trace/route id maybe in payload | Update survey mode status | +| `pcp.route.georeference.v1` | `pcp-route-processing-service` | `pcp-request-service` | `KafkaMessage`, type `RouteGeoRefEvent` | route id maybe in payload | Update request coverage by route | +| `pcp.slots.status.v1` | `pcp-mission-planing-service` | `slots-service` | `BookedSlotsStatusChangedEvent` | missionId/modeId/first bookedSlotId fallback | Update booked slot statuses | +| `pcp.tgu.kpp-update.v1` | Unknown external producer | `pcp-tgu-service` | `SpacecraftPointsMessage` | spacecraft id likely | Update spacecraft points / planning state | + +### 4.3 REST/gRPC dependency map + +| Caller | Callee | Endpoint/method | Request | Response | Purpose | +|--------|--------|-----------------|---------|----------|---------| +| UI/BFF | request-service | `/api/v1/requests`, `/api/requests`, `/api/earth-grid` | request/grid DTOs | request/grid DTOs | UI request/grid operations | +| UI/BFF | satellite-catalog | `/api/satellites`, `/api/satellite-groups` | catalog DTOs | catalog DTOs | UI catalog | +| UI/BFF | slots | `/api/slots`, `/api/slots/booking`, `/api/satellite-ic` | slot/booking DTOs | slot/booking DTOs | UI slots | +| UI/BFF | ballistics | `/api/satellites/**`, `/api/obj-view/**`, `/api/rva` | satellite/time/geometry DTOs | orbit/RVA/coverage DTOs | UI calculations | +| UI/BFF | complex-mission | `/api/com-plan/**`, `/api/satellites/**` | complex plan params | run/mode DTOs | UI complex mission | +| complex-mission | request-service | earth grid/request APIs | interval/cell/request filters | cells/requests | Inputs to complex planning | +| complex-mission | satellite-catalog | satellite APIs | ids/batch | satellite/profile DTOs | Inputs to complex planning | +| complex-mission | ballistics | `/api/obj-view/**`, `/api/satellites/{id}/extract-time` | geometry/time | MPL/cell/position | Ballistics calculations | +| complex-mission | slots | `/api/slots/**`, `/api/slots/booking/**` | slot/booking requests | slots/bookings | Coverage and booking | +| complex-mission | coverage-scheme | `/api/coverage-schemes/calculate` | coverage request | scheme response | Build coverage scheme | +| complex-plan | request-service | request interval lookup | interval | selected requests | New complex-plan run | +| complex-plan | ballistics | flight line/MPL APIs | satellite/time/geometry | flight line/MPL data | New complex-plan run | +| coverage-scheme | ballistics | ballistics APIs | coverage calculation inputs | ballistics DTOs | Coverage scheme algorithm | +| mission-planning | complex-mission | snapshot/mode APIs | mission/snapshot ids | modes | Survey/drop modes | +| mission-planning | Camunda | job complete/publish message | variables | broker ack | BPMN task completion | +| TGU | Camunda | `newCreateInstanceCommand` for `createSatelliteMission` | process variables | process instance | Start satellite mission process | +| Camunda | TGU | job type `updatePlanStatus` | job variables | complete | Confirm tracked plan | +| Camunda | mission-planning | job types listed below | job variables | complete/message | Calculate/send/confirm satellite plan | + +### 4.4 BPM/Zeebe map + +| Process/Worker | Job type | Owning service | Input variables | Output variables | Error mapping | +|----------------|----------|----------------|-----------------|------------------|---------------| +| `createSatelliteMission` process start | n/a | `pcp-tgu-service` | `spacecraftId`, `satellitePlanId`, `planStartTime`, `planEndTime`, `kppId` | process instance key persisted in `tracked_plan` | Exceptions from Camunda command propagate; manual/auto duplicate checks exist | +| Plan status update | `updatePlanStatus` | `pcp-tgu-service` | `satellitePlanId` | none | Missing/blank variable throws exception; worker completes after `planConfirmationService.confirm` | +| Satellite survey planning | `calculateSatelliteSurveyMissions` | `pcp-mission-planing-service` | `satellitePlanId`, `spacecraftId`, `planStartTime`, `planEndTime` | `modes` count | Risk: catch logs exception without explicit fail/rethrow | +| Satellite drop planning | `calculateSatelliteDropMissions` | `pcp-mission-planing-service` | `satellitePlanId` | `modes` count | Risk: catch logs exception without explicit fail/rethrow | +| Send satellite plan | `sendSatellitePlan` | `pcp-mission-planing-service` | `satellitePlanId` | publishes Camunda message `Message_SatellitePlanAccepted` | Risk: catch logs exception without explicit fail/rethrow; contains simulated acceptance delay | +| Update slots status | `updateSlotsStatus` | `pcp-mission-planing-service` | `satellitePlanId` | `slots` ids | Risk: catch logs exception without explicit fail/rethrow | + +BPM domain-data observation: + +- Не видно, что BPM/Zeebe является source-of-truth для доменных данных. Доменные состояния сохраняются в `pcp_tgu`, `pcp_missions`, `pcp_slots` и других сервисных БД. +- BPM variables содержат business identifiers и временные параметры. Это нормально как orchestration context, но не должно превращаться в master data. +- Риск не в том, что BPM явно хранит доменные данные, а в error handling workers и в неочевидной границе между TGU plan и mission-planning mission. + +## 5. Data ownership map + +| Data/domain concept | Owning service | Storage | Producers | Consumers | Notes | +|--------------------|----------------|---------|-----------|-----------|-------| +| Service configuration | `spring-cloud-config-server` / `config-repo` | YAML/git | developers/ops | all services | Infra config, not domain | +| Users/UI pages | `pcp-ui-service` | no domain DB | browser/controllers | browser | BFF only | +| Request | `pcp-request-service` | `pcp_requests.requests` | REST/Kafka `pcp.request.in.v1` | UI, slots, complex-plan, complex-mission | Source-of-truth | +| Request geometry remaining/coverage status | `pcp-request-service` | `requests`, `request_route_matches` | route matching | UI/downstream | Source-of-truth for request coverage by route | +| Earth grid cells | `pcp-request-service` | `earth_cells`, `earth_grid_settings` | grid init/import | request, slots, complex-mission | Source-of-truth inside PCP | +| Request-cell projection | `pcp-request-service` | `request_cells` | projection service | request/slots/complex | Projection/read model | +| Route passport normalized event | `pcp-route-processing-service` | Kafka only | external route source → route-processing | request, mission-planning | Stateless event transformation | +| Satellite catalog | `pcp-satellite-catalog-service` | `satellite` | catalog API | UI, slots, complex, TLE, TGU indirectly | Source-of-truth for satellite metadata | +| Satellite groups/profiles | `pcp-satellite-catalog-service` | group/profile tables | catalog API | UI, slots, complex | Source-of-truth | +| Stations | `pcp-stations-service` | `stations` | station API | UI, mission/coverage | Source-of-truth | +| Raw TLE | `tle-monitoring-service` | `pcp_tle.tle` | CelesTrak/manual API | ballistics via Kafka, TLE API | Source-of-truth for TLE | +| Orbit points / PDCM | `pcp-ballistics-service` | `pdcm` | TLE/IC processing | UI, complex-plan, coverage | Derived source-of-truth | +| Asc nodes | `pcp-ballistics-service` | `asc_node` | ballistics calculation | UI/clients | Derived | +| Flight line | `pcp-ballistics-service` | `flight_line` | ballistics calculation | complex-plan, UI, coverage | Derived | +| Earth coverage/cell covering | `pcp-ballistics-service` | `earth_coverage`, `cell_covering` | ballistics calculation | coverage/complex/UI | Derived/projection | +| Slots | `slots-service` | `slot` | slot calculation/init | UI, complex, mission | Source-of-truth for slots | +| Booked slots | `slots-service` | `booked_slot`, `booked_slot_request` | booking API, mission status events | UI, mission, complex | Source-of-truth for booking status | +| Satellite initial conditions | `slots-service` | `satellite_ic` | IC API | ballistics via Kafka | Source-of-truth for IC in slots context | +| Complex mission modes | `pcp-complex-mission-service` | `satellite_modes`, details | complex calculation | UI, mission-planning | Stateful planning result | +| Complex mission snapshots | `pcp-complex-mission-service` | `satellite_mode_snapshots` | snapshot/calculation | mission-planning/UI | Snapshot/read model | +| Legacy/main complex plan run | `pcp-complex-mission-service` | `complex_plan_run*` in `pcp_satellites` | `/api/com-plan/process` | UI/clients | Overlaps with new complex-plan service | +| New complex plan run | `pcp-dynamic-plan-service` | `pcp_complex_plan.complex_plan_run*` | `/api/v1/complex-plan/runs` | UI/API | Separate new context | +| Complex plan intervals/MPL candidates | `pcp-dynamic-plan-service` | interval/MPL tables | algorithms | reports/API | Derived artifacts | +| Coverage scheme calculation result | `pcp-coverage-scheme-service` | none | REST calculation | complex/UI | Transient unless persisted downstream | +| Missions/plans/modes | `pcp-mission-planing-service` | `missions`, `plans`, `modes`, survey/drop tables | REST/Camunda flow | UI/TGU/process | Source-of-truth for mission state | +| Mode ↔ booked slot relation | `pcp-mission-planing-service` and `slots-service` | `mode_booking` plus `booked_slot` | mission confirmation | mission/slots | Relation in mission DB; booking status source in slots DB | +| TGU tracked plan | `pcp-tgu-service` | `tracked_plan` | scheduler/manual/Camunda | TGU APIs/process | Source-of-truth for TGU plan lifecycle | +| Spacecraft planning state | `pcp-tgu-service` | `spacecraft_planning_state` | Kafka/external API | plan calculation | Projection/state from external points | + +## 6. End-to-end business flows + +### 6.1 TLE update → ballistics recalculation + +1. `tle-monitoring-service` получает список спутников и проверяет TLE. +2. Если TLE нужно обновить, сервис загружает данные из CelesTrak. +3. TLE сохраняется в `pcp_tle.tle`. +4. Событие публикуется в Kafka topic `pcp.tle`. +5. `pcp-ballistics-service` потребляет событие TLE update. +6. Ballistics рассчитывает PDCM/orbit points, asc nodes, flight lines, coverage/cell covering. +7. Производные данные сохраняются в `pcp_ballistics`. +8. Downstream сервисы читают их через REST. + +```mermaid +sequenceDiagram + participant TM as tle-monitoring-service + participant CT as CelesTrak + participant K as Kafka pcp.tle + participant B as pcp-ballistics-service + participant DBT as pcp_tle + participant DBB as pcp_ballistics + + TM->>CT: Load TLE + TM->>DBT: Save raw TLE + TM->>K: Publish TLE event + K-->>B: Consume TLE event + B->>DBB: Persist PDCM/flightline/coverage +``` + +Failure/retry notes: + +- Если DB write и Kafka publish в `tle-monitoring-service` не атомарны, возможно сохранение TLE без события или событие без полного состояния. +- Ballistics processing should be idempotent by satellite/time/revolution, but constraints need direct review before relying on it. + +### 6.2 Initial conditions from slots → ballistics recalculation + +1. Пользователь/API создает или выбирает satellite IC в `slots-service`. +2. `slots-service` публикует `ICRVPlacedEvent`/`SatelliteICDTO` в `pcp.tle`. +3. `pcp-ballistics-service` фильтрует событие по type. +4. Ballistics пересчитывает производные орбитальные данные и сохраняет их. + +```mermaid +sequenceDiagram + participant S as slots-service + participant K as Kafka pcp.tle + participant B as pcp-ballistics-service + participant DBB as pcp_ballistics + + S->>K: Publish ICRVPlacedEvent + K-->>B: Consume IC event + B->>DBB: Recalculate and persist derived orbit data +``` + +Risk: + +- Topic name `pcp.tle` не отражает, что там также живут IC events. + +### 6.3 Route passport → request coverage + mission mode status + +1. Внешний producer отправляет route passport в `pcp.request.survey-georeference.v1`. +2. `pcp-route-processing-service` парсит JSON. +3. Сервис публикует route event в два topic: + - `pcp.route.georeference.v1` для request-service; + - `pcp.route.in.v1` для mission-planning. +4. `pcp-request-service` обрабатывает route geometry: + - находит пересекающиеся заявки; + - проверяет дедупликацию по `(request_id, route_id)`; + - обновляет остаточную геометрию, match counters и status. +5. `pcp-mission-planing-service` обновляет status survey mode по route passport. + +```mermaid +sequenceDiagram + participant EXT as External route source + participant RP as pcp-route-processing-service + participant K1 as Kafka pcp.route.georeference.v1 + participant K2 as Kafka pcp.route.in.v1 + participant R as pcp-request-service + participant M as pcp-mission-planing-service + + EXT->>RP: pcp.request.survey-georeference.v1 + RP->>K1: RouteGeoRefEvent + RP->>K2: ModeStatusChangedEvent + K1-->>R: RoutePassportDto + R->>R: Match route with requests and update coverage + K2-->>M: RoutePassportDto + M->>M: Update survey mode status +``` + +Failure/retry notes: + +- `pcp-route-processing-service` publishes to two topics. Without outbox, partial publish can cause request-service and mission-planning to disagree. +- Request-service has stronger deduplication for route matches than many other consumers. + +### 6.4 Request + slots + booking + +1. Заявка создается в `pcp-request-service` через REST или Kafka. +2. Request-service сохраняет request и projection to Earth grid. +3. `slots-service` получает request/grid data через HTTP. +4. `slots-service` рассчитывает candidate slots/coverage. +5. При booking создаются `booked_slot` и `booked_slot_request`. +6. UI/complex/mission flows читают booking state из slots-service. + +```mermaid +sequenceDiagram + participant U as UI/API + participant R as pcp-request-service + participant S as slots-service + participant DBS as pcp_slots + + U->>R: Create/update request + R->>R: Save request and grid projection + S->>R: Load request/grid + S->>S: Calculate slots/coverage + S->>DBS: Save booked_slot and booked_slot_request +``` + +Failure/retry notes: + +- Source-of-truth request остается в `pcp-request-service`; source-of-truth booking остается в `slots-service`. +- Mission/complex services should not treat local booked slot references as authoritative status. + +### 6.5 Complex mission plan run via `pcp-complex-mission-service` + +1. UI/API вызывает `/api/com-plan/process`. +2. Complex-mission получает request/grid from request-service. +3. Получает satellite/profile from satellite-catalog. +4. Запрашивает ballistics calculations. +5. Запрашивает slots/coverage and booking operations. +6. При необходимости вызывает coverage-scheme-service. +7. Сохраняет satellite modes, snapshots and complex plan run. +8. UI/mission-planning читает modes/snapshots. + +```mermaid +sequenceDiagram + participant UI as UI/API + participant CM as pcp-complex-mission-service + participant R as pcp-request-service + participant C as pcp-satellite-catalog-service + participant B as pcp-ballistics-service + participant S as slots-service + participant CS as pcp-coverage-scheme-service + participant DBCM as pcp_satellites + + UI->>CM: POST /api/com-plan/process + CM->>R: Load requests/grid + CM->>C: Load satellites/profiles + CM->>B: Load MPL/exact-time/cell coverage + CM->>S: Load/book slots + CM->>CS: Calculate coverage scheme + CM->>DBCM: Persist modes/snapshots/run + CM-->>UI: Run response +``` + +Failure/retry notes: + +- This is a long synchronous orchestration chain. +- Compensation strategy for partial slot booking vs persisted complex mission state is not explicit in report-level scan. + +### 6.6 New complex-plan run via `pcp-dynamic-plan-service` + +1. Client calls `POST /api/v1/complex-plan/runs`. +2. Service creates run and stores run metadata. +3. It queries `pcp-request-service` for requests intersecting the interval. +4. It queries `pcp-ballistics-service` for flight line/MPL inputs. +5. It uses internal algorithms to build intervals and MPL candidates. +6. It persists artifacts in `pcp_complex_plan`. +7. Client can read run, result summary and GeoJSON intervals. + +```mermaid +sequenceDiagram + participant U as API/UI + participant CP as pcp-dynamic-plan-service + participant R as pcp-request-service + participant B as pcp-ballistics-service + participant DB as pcp_complex_plan + + U->>CP: POST /api/v1/complex-plan/runs + CP->>DB: Create run + CP->>R: Find requests by interval + CP->>B: Load flight line / MPL inputs + CP->>CP: Build intervals and candidates + CP->>DB: Persist artifacts and result + CP-->>U: Run response +``` + +Failure/retry notes: + +- This flow has explicit persisted run status, which is good. +- It still depends on synchronous downstream calls. +- Its relation to legacy/main complex mission run must be clarified. + +### 6.7 Mission planning + booked slot status update + +1. Mission is created manually or by Camunda worker. +2. `pcp-mission-planing-service` loads satellite modes/snapshots from complex-mission. +3. It creates survey/drop modes and stores mission state. +4. On confirmation it determines booked slot IDs. +5. It publishes `BookedSlotsStatusChangedEvent` to `pcp.slots.status.v1`. +6. `slots-service` consumes the event and updates booked slot statuses. + +```mermaid +sequenceDiagram + participant M as pcp-mission-planing-service + participant CM as pcp-complex-mission-service + participant K as Kafka pcp.slots.status.v1 + participant S as slots-service + participant DBM as pcp_missions + participant DBS as pcp_slots + + M->>CM: Load modes/snapshot + M->>DBM: Save mission/modes/booked slot refs + M->>K: Publish BookedSlotsStatusChangedEvent + K-->>S: Consume status event + S->>DBS: Update booked_slot status +``` + +Failure/retry notes: + +- Local mission DB stores relationships, but booking status authority is in slots-service. +- Without outbox, mission state update and Kafka publish are not guaranteed atomic. + +### 6.8 TGU plan → Camunda process → mission workers + +1. `pcp-tgu-service` receives/loads spacecraft points. +2. It calculates nearest plan and validates platform through classifier. +3. It registers tracked plan and starts Camunda process `createSatelliteMission`. +4. Camunda activates jobs in `pcp-mission-planing-service`: + - calculate survey missions; + - calculate drop missions; + - send satellite plan; + - update slots status. +5. Camunda activates `updatePlanStatus` in `pcp-tgu-service` to confirm tracked plan. + +```mermaid +sequenceDiagram + participant T as pcp-tgu-service + participant CL as External classifier/API + participant C as Camunda + participant M as pcp-mission-planing-service + participant DBT as pcp_tgu + + T->>CL: Validate platform / load points + T->>DBT: Register tracked_plan + T->>C: Start createSatelliteMission + C-->>M: calculateSatelliteSurveyMissions + C-->>M: calculateSatelliteDropMissions + C-->>M: sendSatellitePlan / updateSlotsStatus + C-->>T: updatePlanStatus + T->>DBT: Mark tracked plan confirmed +``` + +Failure/retry notes: + +- TGU worker error handling is clearer than mission-planning workers. +- Mission-planning workers should explicitly fail/rethrow after catch to let Camunda retry/fail jobs correctly. + +## 7. API and message contracts summary + +| Contract | Used by | Direction | Key fields | Purpose | +|----------|---------|-----------|------------|---------| +| `RequestDto` / request DTOs | request-service, UI, external Kafka producers, complex/slots | In/out | request id, interval, geometry, importance, coverage, status | Create/read/update requests | +| `RoutePassportDto` | route-processing, request-service, mission-planning | Kafka out/in | route id, geometry/time/status metadata | Route matching and mode status update | +| `KafkaMessage` | route-processing, TLE, slots, mission | Kafka wrapper | type, data, metadata/correlation | Common event envelope | +| `PcpKafkaEvent` types | Kafka producers/consumers | Kafka headers/payload type | event type names | Consumer filtering/routing | +| `TLEDTO` | TLE monitoring, ballistics | Kafka/REST | satellite/NORAD, revolution, TLE lines/time | TLE update processing | +| `SatelliteICDTO` / IC DTOs | slots, ballistics | REST/Kafka | satellite id, IC/RVA coefficients | Initial condition placement | +| `BookedSlotsStatusChangedEvent` | mission-planning, slots | Kafka | bookedSlotIds, status, occurredAt, sourceService, missionId, modeId, satelliteId | Update booked slots status | +| `SpacecraftPointsMessage` | external/TGU producer, TGU | Kafka | spacecraftId, points/windows | TGU planning state update | +| `MissionRequestDTO` / mission DTOs | TGU/Camunda variables, mission-planning, UI | REST/Camunda | missionId/satellitePlanId, satelliteId, start/stop | Mission creation/planning | +| `ComplexPlanProcessRequestDTO` | UI/API, complex-mission | REST | satellites, interval, filters/options | Legacy/main complex plan run | +| `ComplexPlanRunStartRequestDTO` | clients, dynamic-plan-service | REST | NORAD/satellite, interval, options | New complex-plan manual run | +| `CoverageSchemeCalculateRequestDTO` | complex-mission/UI, coverage-scheme | REST | satellite/mode/geometry/time params | Coverage scheme calculation | +| Satellite catalog DTOs | UI, slots, complex, TLE | REST | satellite id, NORAD, profile params | Satellite metadata/profile contract | +| Station DTOs | UI, mission/coverage | REST | station number/name/location | Station lookup | +| Ballistics DTOs | UI, complex, coverage, TGU | REST | orbit/flightline/RVA/MPL fields | Derived ballistics data | + +Breaking-change risks: + +- `pcp-types-lib` centralizes many DTOs/events. This reduces duplication but also couples services to one shared library release. +- Kafka event type names and topic names are part of public contracts. Topic `pcp.tle` is overloaded by multiple event families. +- REST DTOs used by UI/BFF and orchestration services are effectively internal service contracts; no API versioning is visible for many legacy endpoints. + +## 8. Architecture observations + +### 8.1 Clear service boundaries + +Good boundaries: + +- Request ownership is mostly clear: `pcp-request-service` owns requests, request grid projection and route matches. +- Satellite catalog ownership is clear: `pcp-satellite-catalog-service` owns satellite metadata/profiles/groups. +- Ballistics ownership is mostly clear: `pcp-ballistics-service` owns derived orbital artifacts, not raw TLE. +- Slots ownership is clear for slot/booking source-of-truth. +- Stations ownership is clear. + +Weak/unclear boundaries: + +- `pcp-complex-mission-service` and `pcp-dynamic-plan-service` overlap on complex plan terminology and tables. +- Mission-planning stores `mode_booking` references while slots-service owns `booked_slot` status; this is acceptable as reference data, but status authority must remain explicit. +- Satellite identifiers/NORAD data appear across catalog, TLE, ballistics, slots, complex mission and TGU. Source-of-truth for metadata should remain catalog; derived/stored copies should be documented as snapshots/projections. + +### 8.2 Data ownership conflicts + +Potential conflict: `complex_plan_run` exists in both: + +- `pcp-complex-mission-service` DB `pcp_satellites`; +- `pcp-dynamic-plan-service` DB `pcp_complex_plan`. + +This may be valid if one is legacy and one is new, but the distinction must be explicitly documented in system docs and API naming. Otherwise engineers will not know which run is authoritative. + +### 8.3 Tight coupling + +- `pcp-dynamic-plan-service` has direct Gradle dependency on `:services:pcp-request-service`. This is a service boundary violation unless the dependency is only on shared DTOs/config that should be moved to `pcp-types-lib`. +- `pcp-ui-service` knows many backend endpoints and DTOs. +- Complex orchestration services call many downstream services synchronously. + +### 8.4 Synchronous chains + +The longest sync chains are: + +- UI → complex-mission → request/catalog/ballistics/slots/coverage; +- UI → coverage-scheme → ballistics/complex-mission; +- complex-plan → request + ballistics + catalog; +- TGU → classifier/external API → Camunda → mission-planning workers. + +Risks: + +- cascading latency/timeouts; +- partial state update if one downstream call succeeds and later call fails; +- hard-to-reproduce failures without correlation IDs. + +### 8.5 Kafka reliability and idempotency + +Observed partial positives: + +- Request route matching has uniqueness by `(request_id, route_id)`, giving real deduplication for route coverage. +- Slots DB has uniqueness for booked slot/cycle and booked slot/request links. +- TGU has duplicate/supersede logic for tracked plans. + +Gaps: + +- No consistent transactional outbox pattern found for DB write + Kafka publish. +- No clear DLQ policy in code. +- Kafka consumers generally depend on Spring Kafka error handling; service-level retry semantics are not consistently documented. +- Shared topic `pcp.tle` relies on event type filters. + +### 8.6 BPM/Zeebe usage + +Architecture standard expectation: BPM should orchestrate, while services own domain state. In this code: + +- Domain state is in service DBs, not in BPM as far as static scan shows. +- BPM variables are IDs and timing context, which is acceptable. +- The weak point is worker error handling, especially in `pcp-mission-planing-service` where exceptions are caught/logged without explicit fail/rethrow. + +### 8.7 Observability/correlation + +- Route processing appears to preserve trace/correlation metadata in `KafkaMessage`/metadata. +- Complex-plan run stores `requestedBy` from proxy headers. +- There is no uniform evidence that all HTTP/WebClient calls propagate `correlation_id`/`trace_id`. + +Recommendation: standardize `correlation_id` and `business_key` propagation across REST and Kafka. + +### 8.8 Naming and maintainability + +Naming issues: + +- +- `pcp-srpring-boot-admin-server` typo; +- `pcp-mission-planing-service` typo; +- package names sometimes reflect old service names; +- `pcp_request_service` package appears in ballistics context in inventory. + +These are not just cosmetic: they make search, deployment, alerting and ownership mapping harder. + +### 8.9 Dead/prototype code + +`pcp-complex-mission-flow` exists in repository/config but is commented out in root `settings.gradle.kts`. Treat it as prototype or inactive until deployment confirms otherwise. + +### 8.10 Schema/migration quality + +Positive: + +- Stateful services use Flyway migrations. +- JPA ddl-auto validation is used in configs. +- Important FK/unique constraints exist in request, slots, mission, catalog and complex-plan schemas. + +Risk: + +- Historical migration in TLE fixes Cyrillic `time_reс`; this suggests migration/schema reviews are important. + +## 9. Questions this report can answer later + +This Markdown should help answer questions such as: + +- Какой сервис владеет заявками? +- Где хранится Earth grid? +- Кто публикует `pcp.route.georeference.v1`? +- Кто потребляет `pcp.route.in.v1`? +- Что происходит после загрузки TLE? +- Почему `pcp.tle` потребляет ballistics-service? +- Где хранится `booked_slot` и кто меняет его status? +- Как mission-planning сообщает slots-service о подтверждении слотов? +- Чем отличаются `pcp-complex-mission-service` и `pcp-dynamic-plan-service`? +- Где хранятся satellite modes и snapshots? +- Где хранятся complex-plan intervals/MPL candidates? +- Какой сервис отвечает за каталог спутников? +- Какой сервис отвечает за станции? +- Какие сервисы вызывает UI? +- Какие Kafka topics есть в системе? +- Какие Camunda workers реализованы? +- Хранит ли BPM доменные данные? +- Где потенциально нужен outbox/idempotency? +- Где есть риск tight coupling? +- Какие сервисы нужно изучить при изменении request coverage flow? +- Какие сервисы нужно изучить при изменении slot booking flow? +- Какие сервисы нужно изучить при изменении TGU satellite mission process? + +## 10. Limitations + +Этот отчет не заменяет исходный код полностью. + +Нельзя надежно ответить только по этому отчету на вопросы: + +- точная сигнатура каждого DTO field-by-field; +- все enum values и допустимые status transitions; +- точная математическая корректность алгоритмов ballistics/coverage/complex-plan; +- точные SQL constraints/indexes для каждого поля без открытия migrations; +- точный runtime behavior при конкретной ошибке Kafka/Camunda/HTTP; +- фактическая production topology, если Helm/K8s окружение отличается от config-repo; +- какие endpoints реально используются внешними клиентами; +- SLA/timeout/retry/DLQ настройки, если они задаются вне кода; +- security/authn/authz модель, если она вынесена в ingress/proxy; +- полная BPMN семантика без анализа BPMN diagrams/process definitions; +- поведение inactive/prototype модуля `pcp-complex-mission-flow` в runtime. + +Для точных изменений реализации, bugfix, refactoring, SQL migration или контрактного изменения всё равно нужно открывать соответствующий сервисный код. + +## Appendix A. Modules and shared libraries + +| Module | Role | +|--------|------| +| `libs/pcp-types-lib` | Общие DTO, Kafka wrapper/events, request/satellite/ballistics/mission/contracts | +| `libs/ballistics-lib` | Баллистические/математические алгоритмы, используемые ballistics/coverage services | +| `config-repo` | Централизованные YAML configs | +| `docs/ci-cd-ballistics.md` | CI/CD notes для ballistics | +| `helm/*` | Deployment charts/values for selected services | + +## Appendix B. Most important source paths + +| Path | Why it matters | +|------|----------------| +| `settings.gradle.kts` | Shows active modules and commented `pcp-complex-mission-flow` | +| `config-repo/application-local.yaml` | Local ports, PostgreSQL and Kafka bootstrap | +| `config-repo/application-dev.yaml` | Dev service URLs and ports | +| `config-repo/.yaml` | Service-specific DB/topic/downstream settings | +| `services/*/src/main/kotlin/**/controller` | REST entry points | +| `services/*/src/main/kotlin/**/service` | Business/orchestration logic | +| `services/*/src/main/kotlin/**/entity` | JPA storage ownership | +| `services/*/src/main/resources/db/migration` | Persistent schema and constraints | +| `libs/pcp-types-lib/src/main/kotlin` | Shared API/message contracts | diff --git a/services/pcp-ballistics-service/Dockerfile b/services/pcp-ballistics-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-ballistics-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-ballistics-service/build.gradle.kts b/services/pcp-ballistics-service/build.gradle.kts new file mode 100644 index 0000000..c35f0a9 --- /dev/null +++ b/services/pcp-ballistics-service/build.gradle.kts @@ -0,0 +1,112 @@ + +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +// id("org.graalvm.buildtools.native") +} + +version = "1.0.0" + + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation(project(":libs:ballistics-lib")) +// implementation("org.nstart.dep265:ballistics:2.4.0") + + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") +// implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.4") + implementation("com.github.alexej520:clipper-kotlin:1.0") + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") +// implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + implementation("org.locationtech.jts:jts-core:1.19.0") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + + testRuntimeOnly("com.h2database:h2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + enabled = true + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplication.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplication.kt new file mode 100644 index 0000000..d95de78 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplication.kt @@ -0,0 +1,93 @@ +package space.nstart.pcp.pcp_request_service + +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.ApplicationListener +import org.springframework.core.env.ConfigurableEnvironment +import org.springframework.core.env.EnumerablePropertySource +import org.springframework.core.env.PropertySource +import org.springframework.kafka.annotation.EnableKafka +import org.springframework.scheduling.annotation.EnableAsync + +@SpringBootApplication +@EnableAsync +@EnableKafka +class PcpBallisticsServiceApplication + +fun main(args: Array) { + runApplication(*args) { + addListeners(BallisticsStartupConfigurationLogger()) + } +} + +private class BallisticsStartupConfigurationLogger : ApplicationListener { + private val logger = LoggerFactory.getLogger(BallisticsStartupConfigurationLogger::class.java) + + override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) { + val environment = event.environment + val activeProfiles = environment.activeProfiles.takeIf { it.isNotEmpty() } ?: environment.defaultProfiles + + logger.info( + "Ballistics startup config client: activeProfiles={}, configServerUri={}, configProfile={}, configLabel={}", + activeProfiles.joinToString(","), + maskIfSensitive("spring.cloud.config.uri", environment.getProperty("spring.cloud.config.uri")), + environment.getProperty("spring.cloud.config.profile"), + environment.getProperty("spring.cloud.config.label"), + ) + + val configSources = environment.configServerPropertySources() + if (configSources.isEmpty()) { + logger.warn("Ballistics received no config server property sources") + return + } + + configSources.forEach { source -> + logger.info("Ballistics received config source '{}': {}", source.name, source.printableProperties(environment)) + } + } + + private fun ConfigurableEnvironment.configServerPropertySources(): List> = + propertySources + .asSequence() + .filter { it.name.startsWith("configserver:") || it.name == "configClient" } + .toList() + + private fun PropertySource<*>.printableProperties(environment: ConfigurableEnvironment): String { + if (this !is EnumerablePropertySource<*>) { + return "" + } + + return propertyNames + .sorted() + .joinToString(", ") { propertyName -> + formatProperty(propertyName, getProperty(propertyName), environment.getProperty(propertyName)) + } + } + + private fun formatProperty(propertyName: String, rawValue: Any?, resolvedValue: String?): String { + val printableResolvedValue = maskIfSensitive(propertyName, resolvedValue) + val printableRawValue = maskIfSensitive(propertyName, rawValue) + + if (printableRawValue == printableResolvedValue) { + return "$propertyName=$printableResolvedValue" + } + + return "$propertyName=$printableResolvedValue (raw=$printableRawValue)" + } + + private fun maskIfSensitive(propertyName: String, value: Any?): String { + if (value == null) { + return "" + } + + val normalizedName = propertyName.lowercase() + val sensitiveMarkers = listOf("password", "passwd", "secret", "token", "credential", "private-key", "api-key", "apikey") + return if (sensitiveMarkers.any { normalizedName.contains(it) }) { + "******" + } else { + value.toString() + } + } +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/GlobalExceptionHandler.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..bfe21a5 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_request_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(WebExchangeBindException::class) + fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { + val errors = mutableMapOf() + ex.bindingResult.fieldErrors.forEach { error -> + errors[error.field] = error.defaultMessage ?: "Validation error" + } + return Mono.just(ResponseEntity.badRequest().body(errors)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/KafkaConfig.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/KafkaConfig.kt new file mode 100644 index 0000000..32e5f85 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/KafkaConfig.kt @@ -0,0 +1,138 @@ +package space.nstart.pcp.pcp_request_service.configuration + +import org.apache.kafka.clients.admin.AdminClientConfig +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.header.Header +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaAdmin +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.listener.adapter.RecordFilterStrategy +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.nio.charset.StandardCharsets +import kotlin.String + + +@ConditionalOnProperty(name=["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + companion object { + const val HEADER_NAME: String = "type" + const val TARGET_CONSUMER_HEADER_NAME: String = "target_consumer" + } + + @Value("\${spring.kafka.template.default-topic}") + private lateinit var defaultTopic: String + + @Bean + fun tleTopic(): NewTopic = + NewTopic(defaultTopic, 1, 1) + + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var addr: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean + fun template(): KafkaTemplate { + return KafkaTemplate(producerFactory()) + } + + @Bean + fun kafkaAdmin(): KafkaAdmin { + + val configs = hashMapOf() + configs[AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG] = addr + + return KafkaAdmin(configs) + } + + @Bean + fun consumerFactory(): ConsumerFactory { + val props = hashMapOf() + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr + props[ConsumerConfig.GROUP_ID_CONFIG] = groupId + props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "latest" + props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + return DefaultKafkaConsumerFactory(props) + } + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory() + .apply { this.setConsumerFactory(consumerFactory()) } + + @Bean + fun producerFactory(): ProducerFactory { + + val configProps = hashMapOf() + configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr + configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + + return DefaultKafkaProducerFactory(configProps) + } + + private fun getRecordFilterStrategy(event: PcpKafkaEvent): RecordFilterStrategy { + return RecordFilterStrategy { consumerRecord: ConsumerRecord -> + val eventTypeHeader: Header? = consumerRecord.headers().lastHeader(HEADER_NAME) + if (eventTypeHeader != null) { + val headerValue = String(eventTypeHeader.value(), StandardCharsets.UTF_8) + return@RecordFilterStrategy event.name != headerValue + } + true + } + } + + private fun getRecordFilterStrategyByConsumer(): RecordFilterStrategy { + return RecordFilterStrategy { consumerRecord: ConsumerRecord -> + val consumerGroupHeader: Header? = consumerRecord.headers().lastHeader(TARGET_CONSUMER_HEADER_NAME) + if (consumerGroupHeader != null) { + val headerValue = String(consumerGroupHeader.value(), StandardCharsets.UTF_8) + return@RecordFilterStrategy "respondedListener" != headerValue + } + true + } + } + + @Bean("placedIcFilter") + fun placedFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.ICPlacedEvent) + } + + @Bean("placedIcRvFilter") + fun placedIcRvFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.ICRVPlacedEvent) + } + + @Bean("updatedIcFilter") + fun updatedIcFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.ICUpdatedEvent) + } + + @Bean("satelliteDeletedFilter") + fun satelliteDeletedFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.SatelliteDeletedEvent) + } + + @Bean("respondedFilter") + fun respondedFilter(): RecordFilterStrategy { + return getRecordFilterStrategyByConsumer() + } +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/ValidationConfig.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/ValidationConfig.kt new file mode 100644 index 0000000..c5ccd88 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/configuration/ValidationConfig.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_request_service.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator { + return Validation.buildDefaultValidatorFactory().validator + } +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/ObjViewController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/ObjViewController.kt new file mode 100644 index 0000000..0a9ac21 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/ObjViewController.kt @@ -0,0 +1,37 @@ +package space.nstart.pcp.pcp_request_service.controller + +import jakarta.validation.Valid +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.SatelliteService +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO + + +@RestController +@RequestMapping("/api/obj-view") +class ObjViewController { + + @Autowired + private lateinit var satelliteService: SatelliteService + + @PostMapping("/mpl-square") + fun mplSquare( + @Valid @RequestBody body : ObjViewRequestDTO + ) = + satelliteService.mplSquare(body) + + @PostMapping("/mpl-point") + fun mplPoint( + @Valid @RequestBody body : ObjViewRequestDTO + ) = + satelliteService.mplPoint(body) + + @GetMapping("cell-coverage/{number}") + fun cellCov(@PathVariable number : Long) = satelliteService.getCellCovering(number) + +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/RVAController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/RVAController.kt new file mode 100644 index 0000000..52ee95a --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/RVAController.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.pcp_request_service.controller + +import org.springframework.beans.factory.annotation.Autowired +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.RVAService +import space.nstart.pcp.pcp_types_lib.dto.ballistics.IcRvaRequestDTO + + +@RestController +@RequestMapping("api/rva") +class RVAController { + + @Autowired + private lateinit var rvaService: RVAService + + @PostMapping + fun getZRV(@RequestBody body : IcRvaRequestDTO) = rvaService.calculateRVA(body) +} \ No newline at end of file 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 new file mode 100644 index 0000000..925231e --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt @@ -0,0 +1,126 @@ +package space.nstart.pcp.pcp_request_service.controller + +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.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +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 java.time.LocalDateTime + + +@RestController +@RequestMapping("/api/satellites") +class SatelliteController { + @Autowired + private lateinit var satelliteService: SatelliteService + + @PostMapping("/{satellite_id}/extract-time") + fun extractTime( + @PathVariable("satellite_id") satelliteId : Long, + @Valid @RequestBody body : ExactTimePositionRequestDTO + ) = + satelliteService.exactTime(satelliteId, body) + + + + @Operation( + summary = "Получить ПДЦМ", + description = "Возвраает ПДЦМ для заданного аппарата на заданном интервале времени с шагом 60 с" + ) + @GetMapping("/{norad_id}/orbit") + fun getPoints( + @Parameter(description = "Идентификатор NORAD", example = "56756") + @PathVariable("norad_id") noradId : Long, + @Parameter(description = "Время начала (если null, то мин. время для выбранного КА)") + @RequestParam("time_start") timeStart : LocalDateTime?, + @Parameter(description = "Время конца (если null, то макс. время для выбранного КА)") + @RequestParam("time_stop") timeStrop : LocalDateTime? + ) = + satelliteService.getOrbPoints(noradId, timeStart, timeStrop) + + @Operation( + summary = "Получить доступные интервалы прогноза по всем КА", + description = "Возвращает минимальное и максимальное время ПДЦМ для каждого КА. Если time задан, возвращаются только КА, у которых это время попадает в доступный интервал" + ) + @GetMapping("/orbit/availability") + fun getOrbitAvailability( + @Parameter(description = "Проверяемое время. Если null, возвращаются все КА, для которых есть точки ПДЦМ") + @RequestParam("time", required = false) time: LocalDateTime? + ) = + satelliteService.getOrbitAvailability(time) + + + @GetMapping("/orbit/availability/{id}") + fun getOrbitAvailabilityById( + @PathVariable id : Long + ) = + satelliteService.getOrbitAvailability(null).filter { it.satelliteId == id }.firstOrNull()?: + throw CustomValidationException("Спутник с заданным идентификатором не зарегистрирован") + + + @Operation( + summary = "Получить параметры прохождения ВУЗов", + description = "Возвраает массив ВУЗов для заданного аппарата на заданном интервале времени" + ) + @GetMapping("/{norad_id}/asc-node") + fun getAskNodes( + @Parameter(description = "Идентификатор NORAD", example = "56756") + @PathVariable("norad_id") noradId : Long, + @Parameter(description = "Время начала (если null, то мин. время для выбранного КА)") + @RequestParam("time_start") timeStart : LocalDateTime?, + @Parameter(description = "Время конца (если null, то макс. время для выбранного КА)") + @RequestParam("time_stop") timeStrop : LocalDateTime? + ) = + satelliteService.getAscNodes(noradId, timeStart, timeStrop) + + + @Operation( + summary = "Получить параметры трассы полета и полосы обзора", + description = "Возвращает параметры ТП и ПО на заданном интервале времен с шагом 60 с" + ) + @GetMapping("/{norad_id}/flight-line") + fun getFL( + @Parameter(description = "Идентификатор NORAD", example = "56756") + @PathVariable("norad_id") noradId : Long, + @Parameter(description = "Время начала (если null, то мин. время для выбранного КА)") + @RequestParam("time_start") timeStart : LocalDateTime?, + @Parameter(description = "Время конца (если null, то макс. время для выбранного КА)") + @RequestParam("time_stop") timeStrop : LocalDateTime? + ) = + satelliteService.getFL(noradId, timeStart, timeStrop, 60.0) + + + + + @Operation( + summary = "Получить параметры ЗРВ", + description = "Возвращает параметры ЗРВ на заданном интервале времен" + ) + @GetMapping("/{norad_id}/rva") + fun getRVA( + @Parameter(description = "Идентификатор NORAD", example = "56756") + @PathVariable("norad_id") noradId : Long, + @Parameter(description = "Время начала (если null, то мин. время для выбранного КА)") + @RequestParam("time_start") timeStart : LocalDateTime, + @Parameter(description = "Время конца (если null, то макс. время для выбранного КА)") + @RequestParam("time_stop") timeStrop : LocalDateTime + ) = + satelliteService.getRVA(noradId, timeStart, timeStrop) + + + @PostMapping("/{satellite_id}/clear") + fun clear( + @PathVariable("satellite_id") satelliteId : Long + ) = + satelliteService.clear(satelliteId) + +} 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 new file mode 100644 index 0000000..7a6c6d8 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/TLEController.kt @@ -0,0 +1,30 @@ +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.TleRvaRequestDTO + +@RestController +@RequestMapping("/api/tle") +class TLEController { + + @Autowired + private lateinit var tleService: TLEService + + + @PostMapping("/parse") + fun orbit(@RequestBody tle : TLEDTO) = tleService.parseTLE(tle) + + + @PostMapping("/rva") + fun rva(@RequestBody req : TleRvaRequestDTO) = tleService.rva(req) + +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt new file mode 100644 index 0000000..a7673b2 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt @@ -0,0 +1,110 @@ +package space.nstart.pcp.pcp_request_service.entity + +import ballistics.types.KeplerParams +import ballistics.types.RevolutionParameter +import ballistics.utils.fromDateTime +import ballistics.utils.toDateTime +import jakarta.persistence.Column +import jakarta.persistence.Entity +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.AscNodeDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO +import java.time.LocalDateTime +import kotlin.math.PI + + +@Entity +@Table(name = "asc_node") +class AscNodeEntity( + @Id + @Column(name = "asc_node_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + @Column(nullable = false) + val satelliteId : Long = 0, + @Column(name = "t", nullable = false) + val time : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + val revolution : Long = 0, + val longitude : Double = 0.0, + val height : Double = 0.0, + val x: Double = 0.0, + val y: Double = 0.0, + val z: Double = 0.0, + val vx: Double = 0.0, + val vy: Double = 0.0, + val vz: Double = 0.0, +) { + constructor(an : RevolutionParameter, sat : Long) : this( + null, + sat, + toDateTime(an.vuz.t), + an.vuz.vit.toLong(), + an.lVuz * 180 / PI, + an.hVuz, + an.vuz.r.x, + an.vuz.r.y, + an.vuz.r.z, + an.vuz.v.x, + an.vuz.v.y, + an.vuz.v.z, + ) + + private companion object { + const val SECONDS_IN_DAY = 86_400.0 + const val NANOS_IN_DAY = 86_400_000_000_000L + } + + private fun positiveModulo(value: Double, modulo: Double): Double { + val remainder = value % modulo + return if (remainder < 0) remainder + modulo else remainder + } + + /** + * Местное среднее солнечное время на долготе longitudeRad. + * Используется та же идея, что в tmest(t, Lvuz): UTC-время суток плюс поправка по долготе. + */ + private fun localMeanSolarSeconds(epochSeconds: Double, longitudeRad: Double): Double { + val utcSecondsOfDay = positiveModulo(epochSeconds, SECONDS_IN_DAY) + val longitudeShiftSeconds = longitudeRad / (2.0 * PI) * SECONDS_IN_DAY + return positiveModulo(utcSecondsOfDay + longitudeShiftSeconds, SECONDS_IN_DAY) + } + + + fun tmest(t : LocalDateTime, l : Double) : LocalDateTime + { + var dl = l; + if (dl > PI) + dl = dl - 2 * PI; + var tm = t.plusSeconds(-60*60*3); + tm = tm.plusNanos((dl/2.0/PI * 86400.0*1e9).toLong()); + return tm; + + } + + fun toDTO(keps : KeplerParams, rekv : Double) = AscNodeDTO( + time = this.time, + revolution = this.revolution.toInt(), + long = this.longitude, + height = this.height, + x = this.x, + y = this.y, + z = this.z, + vx = this.vx, + vy = this.vy, + vz = this.vz, + keps = KeplersDTO( + (keps.ael - rekv) / 1000.0, + keps.e, + keps.nakl * 180 / PI, + keps.omegab * 180 / PI, + keps.omegam * 180 / PI + ), + localMeanTime = tmest(this.time, this.longitude * PI / 180.0) + ) +} + + diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt new file mode 100644 index 0000000..0e84c33 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt @@ -0,0 +1,58 @@ +package space.nstart.pcp.pcp_request_service.entity + +import ballistics.utils.toDateTime +import jakarta.persistence.Column +import jakarta.persistence.Entity +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.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.LocalDateTime +import kotlin.math.PI + +@Entity +@Table(name = "cell_covering") +class EarthCellViewsEntity( + @Id + @Column(name = "cell_covering_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + @Column(nullable = false) + val satelliteId : Long = 0, + @Column(nullable = false) + val cellNum : Long = 0, + @Column(name = "rev_begin", nullable = false) + val revolutionBegin : Long = 0, + @Column(nullable = false) + val timeBegin : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + val revSignBegin : Int = 0, + @Column(name = "rev_end", nullable = false) + val revolutionEnd: Long = 0, + @Column(nullable = false) + val timeEnd : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + val revSignEnd : Int = 0, + @Column(name = "roll_min") + val gammaMin : Double = 0.0, + @Column(name = "roll_max") + val gammaMax : Double = 0.0, + @Column(name = "contour_wkt", nullable = false) + val contour : String = "" +) { + + fun toDTO() = SquareViewParamDTO( + this.satelliteId, + this.cellNum.toString(), + this.revolutionBegin, + this.timeBegin, + if (this.revSignBegin == 0) RevolutionSign.ASC else RevolutionSign.DESC, + this.revolutionBegin, + this.timeEnd, + if (this.revSignEnd == 0) RevolutionSign.ASC else RevolutionSign.DESC, + this.gammaMin, + this.gammaMax + ) +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt new file mode 100644 index 0000000..2a924d5 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp.pcp_request_service.entity + +import ballistics.types.FleghtLineSector +import ballistics.utils.toDateTime +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "earth_coverage") +class EarthCoverageEntity( + @Id + @Column(name = "earth_coverage_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + @Column(nullable = false) + val satelliteId : Long = 0, + @Column(nullable = false) + val cellNumber : Long = 0, + @Column(nullable = false) + val tStart : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + val tStop : LocalDateTime = LocalDateTime.now(), + ) { + constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this( + null, + sat, + sector.toLong(), + toDateTime(fl.tStart), + toDateTime(fl.tStop) + ) +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt new file mode 100644 index 0000000..7d72c1e --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt @@ -0,0 +1,77 @@ +package space.nstart.pcp.pcp_request_service.entity + +import ballistics.types.FlightLine +import ballistics.utils.toDateTime +import jakarta.persistence.* +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime +import kotlin.math.PI + + +@Entity +@Table(name = "flight_line") +class FlightLineEntity( + @Id + @Column(name = "flight_line_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + @Column(nullable = false) + val satelliteId : Long = 0, + @Column(name = "t", nullable = false) + val time : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + val revolution : Long = 0, + @Column(nullable = false) + val revolutionSign: String = RevolutionSign.ASC.toString(), + val longitude : Double = 0.0, + val latitude : Double = 0.0, + val longitudeOuterLeft : Double = 0.0, + val latitudeOuterLeft : Double = 0.0, + val longitudeInnerLeft : Double = 0.0, + val latitudeInnerLeft : Double = 0.0, + val longitudeInnerRight : Double = 0.0, + val latitudeInnerRight : Double = 0.0, + val longitudeOuterRight : Double = 0.0, + val latitudeOuterRight : Double = 0.0, +) { + constructor(fl : FlightLine, sat : Long) : this( + null, + sat, + toDateTime(fl.t), + fl.vit.toLong(), + if (fl.pv == 0) RevolutionSign.ASC.toString() else RevolutionSign.DESC.toString(), + + fl.flightLine.long * 180 / PI, + fl.flightLine.lat * 180 / PI, + + + fl.leftOuterSwath.long * 180 / PI, + fl.leftOuterSwath.lat * 180 / PI, + + fl.leftInternalSwath.long * 180 / PI, + fl.leftInternalSwath.lat * 180 / PI, + + fl.rightInternalSwath.long * 180 / PI, + fl.rightInternalSwath.lat * 180 / PI, + + fl.rightOuterSwath.long * 180 / PI, + fl.rightOuterSwath.lat * 180 / PI, + ) + + fun toDTO() = FlightLineDTO( + time = time, + revolution = revolution, + lat = latitude , + long = longitude, + latLeft = latitudeOuterLeft, + longLeft = longitudeOuterLeft, + latInnerLeft = latitudeInnerLeft, + longInnerLeft = longitudeInnerLeft, + latInnerRight = latitudeInnerRight, + longInnerRight = longitudeInnerRight, + latRight = latitudeOuterRight, + longRight = longitudeOuterRight, + revSign = RevolutionSign.valueOf(revolutionSign) + ) +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt new file mode 100644 index 0000000..afe03da --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt @@ -0,0 +1,56 @@ +package space.nstart.pcp.pcp_request_service.entity + +import ballistics.types.OrbitalPoint +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import ballistics.utils.toDateTime +import jakarta.persistence.* +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import java.time.LocalDateTime + +@Entity +@Table(name = "pdcm") +class PDCMEntity ( + @Id + @Column(name = "pdcm_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + @Column(nullable = false) + val satelliteId : Long = 0, + @Column(name = "t", nullable = false) + val time : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + val revolution : Long = 0, + val x: Double = 0.0, + val y: Double = 0.0, + val z: Double = 0.0, + val vx: Double = 0.0, + val vy: Double = 0.0, + val vz: Double = 0.0, +){ + constructor(op : OrbitalPoint, sat : Long) : this( + null, + sat, + toDateTime(op.t), + op.vit.toLong(), + op.r.x, + op.r.y, + op.r.z, + op.v.x, + op.v.y, + op.v.z + ) + + fun toOrbitalPoint() = OrbitalPoint( + t = fromDateTime(time), + vit = revolution.toInt(), + r = Vector3D(x,y,z), + v = Vector3D(vx, vy,vz) + ) + + fun toDTO() = OrbPointDTO( + time = time, + revolution, + vx,vy,vz,x,y,z + ) +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/KafkaListener.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/KafkaListener.kt new file mode 100644 index 0000000..39a1f01 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/KafkaListener.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.pcp_request_service.message + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component + + +@Component +class KafkaListener { + private var log: Logger = LoggerFactory.getLogger(this::class.java) + + @KafkaListener(id = "placedIcFilter", topics = ["\${spring.kafka.template.default-topic}"], filter = "placedIcFilter") + fun listener(data: String?) { + log.info("Received message [{}] in group1 with header1", data) + } + + +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListener.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListener.kt new file mode 100644 index 0000000..aff06f8 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListener.kt @@ -0,0 +1,66 @@ +package space.nstart.pcp.pcp_request_service.message + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_request_service.service.SatelliteService +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import tools.jackson.databind.ObjectMapper + +private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-ballistics-service-satellite-deleted" + +@Component +class SatelliteDeletedKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteService: SatelliteService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable + ?: throw IllegalStateException("ObjectMapper bean is required for SatelliteDeletedKafkaListener") + + @KafkaListener( + id = "satelliteDeletedFilter", + topics = ["\${app.kafka.topics.satellites:pcp.satellites}"], + groupId = SATELLITE_DELETED_CONSUMER_GROUP, + filter = "satelliteDeletedFilter" + ) + fun consume(message: String?) { + if (message.isNullOrBlank()) { + logger.warn("Received empty SatelliteDeletedEvent Kafka message") + return + } + + try { + val rootNode = objectMapper.readTree(message) + val payloadNode = rootNode["data"] ?: throw IllegalArgumentException("В сообщении отсутствует поле data") + val event = objectMapper.treeToValue(payloadNode, SatelliteDeletedEventDTO::class.java) + logger.info( + "Received SatelliteDeletedEvent in ballistics-service: satelliteId={}, noradId={}, consumerGroup={}", + event.satelliteId, + event.noradId, + SATELLITE_DELETED_CONSUMER_GROUP + ) + deleteByIdentifiers(event) + } catch (exception: Exception) { + logger.error("Failed to process SatelliteDeletedEvent in ballistics-service: {}", exception.message, exception) + throw exception + } + } + + private fun deleteByIdentifiers(event: SatelliteDeletedEventDTO) { + event.deleteIdentifiers().forEach { satelliteId -> + satelliteService.clear(satelliteId) + } + logger.info( + "SatelliteDeletedEvent processed in ballistics-service: satelliteId={}, noradId={}, consumerGroup={}", + event.satelliteId, + event.noradId, + SATELLITE_DELETED_CONSUMER_GROUP + ) + } + + private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List = + listOfNotNull(satelliteId, noradId).distinct() +} 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 new file mode 100644 index 0000000..f56e06e --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListener.kt @@ -0,0 +1,70 @@ +package space.nstart.pcp.pcp_request_service.message + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +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.SatelliteICDTO +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.node.ObjectNode + +@Component +class SatelliteIcRvKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteIcEventService: SatelliteIcEventService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable + ?: throw IllegalStateException("ObjectMapper bean is required for SatelliteIcRvKafkaListener") + + @KafkaListener( + id = "placedIcRvFilter", + topics = ["\${spring.kafka.template.default-topic}"], + filter = "placedIcRvFilter" + ) + fun listener(data: String?) { + consume(data) + } + + fun consume(data: String?) { + if (data.isNullOrBlank()) { + logger.warn("Received empty satellite initial conditions Kafka message") + return + } + + try { + logger.info("Received satellite initial conditions Kafka message body: {}", data) + val rootNode = objectMapper.readTree(data) + val payloadNode = rootNode["data"] ?: throw IllegalArgumentException("В сообщении отсутствует поле data") + val payload = objectMapper.treeToValue(normalizePayload(payloadNode), SatelliteICDTO::class.java) + satelliteIcEventService.handlePlacedIcRv(payload) + } catch (exception: Exception) { + logger.error("Ошибка обработки сообщения о начальных условиях КА: {}", exception.message, exception) + throw exception + } + } + + /** + * Keeps consumer backward compatible with already published messages where optional IC coefficients were null. + */ + private fun normalizePayload(payloadNode: tools.jackson.databind.JsonNode): tools.jackson.databind.JsonNode { + val normalizedPayload = payloadNode.deepCopy() + val initialConditionsNode = normalizedPayload["ic"] + if (initialConditionsNode is ObjectNode) { + if (initialConditionsNode["sBall"] == null || initialConditionsNode["sBall"].isNull) { + initialConditionsNode.put("sBall", DEFAULT_INITIAL_CONDITIONS.sBall) + } + if (initialConditionsNode["f81"] == null || initialConditionsNode["f81"].isNull) { + initialConditionsNode.put("f81", DEFAULT_INITIAL_CONDITIONS.f81) + } + } + return normalizedPayload + } + + companion object { + private val DEFAULT_INITIAL_CONDITIONS = InitialConditionsDTO() + } +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/model/SquareViewParamsModel.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/model/SquareViewParamsModel.kt new file mode 100644 index 0000000..9de9279 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/model/SquareViewParamsModel.kt @@ -0,0 +1,39 @@ +package space.nstart.pcp.pcp_request_service.model + +import ballistics.utils.toDateTime +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import kotlin.math.PI + +data class SquareViewParamsModel( + var id: Long = 0, + var objID : String, + var pointNumb: Int = 0, + var vitN: Int = 0, + var tN: Double = 0.0, + var pvN : RevolutionSign = RevolutionSign.ASC, + var vitK: Int = 0, + var tK: Double = 0.0, + var pvK: RevolutionSign = RevolutionSign.ASC, + var krenMin: Double = 0.0, + var krenMax: Double = 0.0 +): Cloneable { + + fun toDTO(): SquareViewParamDTO = + SquareViewParamDTO( + this.id, + this.objID, + this.vitN.toLong(), + toDateTime(this.tN), + this.pvN, + this.vitK.toLong(), + toDateTime(this.tK), + this.pvK, + this.krenMin * 180 / PI, + this.krenMax * 180 / PI, + ) + + public override fun clone(): SquareViewParamsModel { + return super.clone() as SquareViewParamsModel + } +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/AscNodeRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/AscNodeRepository.kt new file mode 100644 index 0000000..43f7137 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/AscNodeRepository.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.pcp_request_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_request_service.entity.AscNodeEntity +import java.time.LocalDateTime + + +interface AscNodeRepository : JpaRepository{ + + @Modifying + @Transactional + @Query("DELETE FROM asc_node WHERE satellite_id =:sat", nativeQuery = true) + fun deleteBySatId(@Param("sat") sat : Long) : Int + + + fun findBySatelliteId(id : Long) : List + + fun findBySatelliteIdAndTimeBetween(id : Long, t1 : LocalDateTime, t2 : LocalDateTime) : List + + fun findBySatelliteIdAndTimeAfter(id : Long, t : LocalDateTime): List + + fun findBySatelliteIdAndTimeBefore(id : Long, t : LocalDateTime): List +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/EarthCellViewRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/EarthCellViewRepository.kt new file mode 100644 index 0000000..fcd3bb9 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/EarthCellViewRepository.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.pcp_request_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_request_service.entity.AscNodeEntity +import space.nstart.pcp.pcp_request_service.entity.EarthCellViewsEntity + +interface EarthCellViewRepository : JpaRepository{ + @Modifying + @Transactional + @Query("DELETE FROM cell_covering WHERE satellite_id =:sat", nativeQuery = true) + fun deleteBySatId(@Param("sat") sat : Long) : Int + + + fun findBySatelliteId(id : Long) : List + + fun findByCellNum(num : Long) : List + +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/EarthCoverageRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/EarthCoverageRepository.kt new file mode 100644 index 0000000..ffd17d1 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/EarthCoverageRepository.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.pcp_request_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_request_service.entity.EarthCoverageEntity +import java.time.LocalDateTime + + +interface EarthCoverageRepository : JpaRepository{ + + @Modifying + @Transactional + @Query("DELETE FROM earth_coverage WHERE satellite_id =:sat", nativeQuery = true) + fun deleteBySatId(@Param("sat") sat : Long) : Int + + + fun findBySatelliteIdAndTStartGreaterThanAndTStopLessThanAndCellNumberIn + (id : Long, tn : LocalDateTime, tk: LocalDateTime, cells : List) : List +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/FlightLineRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/FlightLineRepository.kt new file mode 100644 index 0000000..dc817d2 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/FlightLineRepository.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.pcp_request_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_request_service.entity.FlightLineEntity +import java.time.LocalDateTime + + +interface FlightLineRepository : JpaRepository{ + + @Modifying + @Transactional + @Query("DELETE FROM flight_line WHERE satellite_id =:sat", nativeQuery = true) + fun deleteBySatId(@Param("sat") sat : Long) : Int + + + fun findBySatelliteIdAndTimeBetween(id : Long, tn : LocalDateTime, tk: LocalDateTime) : List +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt new file mode 100644 index 0000000..766a9bd --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt @@ -0,0 +1,49 @@ +package space.nstart.pcp.pcp_request_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_request_service.entity.PDCMEntity +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import java.time.LocalDateTime + +interface PDCMRepository : JpaRepository{ + + @Modifying + @Transactional + @Query("DELETE FROM pdcm WHERE satellite_id =:sat", nativeQuery = true) + fun deleteBySatId(@Param("sat") sat : Long) : Int + + fun findBySatelliteIdAndTimeBetween(id : Long, tBegin : LocalDateTime, tEnd : LocalDateTime) : List + + @Query( + """ + select new space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO( + p.satelliteId, + min(p.time), + max(p.time) + ) + from PDCMEntity p + group by p.satelliteId + order by p.satelliteId + """ + ) + fun findSatelliteOrbitAvailability(): List + + @Query( + """ + select new space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO( + p.satelliteId, + min(p.time), + max(p.time) + ) + from PDCMEntity p + group by p.satelliteId + having :time between min(p.time) and max(p.time) + order by p.satelliteId + """ + ) + fun findSatelliteOrbitAvailabilityAtTime(@Param("time") time: LocalDateTime): List +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/RVAService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/RVAService.kt new file mode 100644 index 0000000..66c19a5 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/RVAService.kt @@ -0,0 +1,97 @@ +package space.nstart.pcp.pcp_request_service.service + +import ballistics.Ballistics +import ballistics.types.BallisticsError +import ballistics.types.InitialConditions +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.types.PPI +import ballistics.types.VisibilityParametersZRV +import ballistics.types.ZRV +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import ballistics.utils.toDateTime +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_request_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.ballistics.IcRvaRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO +import kotlin.math.PI + + +@Service +class RVAService { + + + + fun calculateRVA(icRvaRequest : IcRvaRequestDTO) : List { + + + val bal = Ballistics() + val mdv = ModDVType.fromInt((icRvaRequest.modDvType ?: 1).toInt()) + + bal.modDVType = mdv + var result = bal.calculateOrbPoints( + InitialConditions( + OrbitalPoint( + fromDateTime(icRvaRequest.ic.orbPoint.time), + icRvaRequest.ic.orbPoint.revolution.toInt(), + Vector3D( + icRvaRequest.ic.orbPoint.x, + icRvaRequest.ic.orbPoint.y, + icRvaRequest.ic.orbPoint.z, + ), + Vector3D( + icRvaRequest.ic.orbPoint.vx, + icRvaRequest.ic.orbPoint.vy, + icRvaRequest.ic.orbPoint.vz, + ) + ), + icRvaRequest.ic.sBall, + icRvaRequest.ic.f81 + ), + fromDateTime(icRvaRequest.timeStart), + fromDateTime(icRvaRequest.timeStop) + ) + + if (result != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета ПДЦМ. Код ошибки : $result") + + result = bal.calculateZRV( + listOf( + PPI( + 1, 1, + icRvaRequest.station.position.lat / 180 * PI, + icRvaRequest.station.position.long / 180 * PI, + icRvaRequest.station.position.height, + icRvaRequest.station.elevationMin / 180 * PI, + icRvaRequest.station.elevationMax / 180 * PI, + null, null + ) + ), + fromDateTime(icRvaRequest.timeStart), + fromDateTime(icRvaRequest.timeStop) + ) + if (result != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета ЗРВ. Код ошибки : $result") + + return bal.zrv.map { mkRVA(it, 0L) } + } + + + fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO( + toDateTime(p.t), + p.elevation * 180 / PI, + p.azimuth * 180 / PI, + p.range + ) + + fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO( + noradId = id, + stationId = p.ppi.toLong(), + revolution = p.vit.toLong(), + onStart = mkTP(p.zoneIn), + onMaximum = mkTP(p.zoneMax), + onStop = mkTP(p.zoneOut) + ) +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteIcEventService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteIcEventService.kt new file mode 100644 index 0000000..c83a621 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteIcEventService.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_request_service.service + +import ballistics.Ballistics +import ballistics.types.BallisticsError +import ballistics.types.TLE +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_request_service.entity.AscNodeEntity +import space.nstart.pcp.pcp_request_service.entity.EarthCoverageEntity +import space.nstart.pcp.pcp_request_service.entity.FlightLineEntity +import space.nstart.pcp.pcp_request_service.entity.PDCMEntity +import space.nstart.pcp.pcp_request_service.repository.AscNodeRepository +import space.nstart.pcp.pcp_request_service.repository.EarthCellViewRepository +import space.nstart.pcp.pcp_request_service.repository.EarthCoverageRepository +import space.nstart.pcp.pcp_request_service.repository.FlightLineRepository +import space.nstart.pcp.pcp_request_service.repository.PDCMRepository +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.math.PI + +@Service +open class SatelliteIcEventService { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Autowired + private lateinit var satelliteService: SatelliteService + + open fun handlePlacedIcRv(message: SatelliteICDTO) { + CoroutineScope(Dispatchers.Default).launch { + satelliteService.resieveRV(message) + } + } + + + + + +} 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 new file mode 100644 index 0000000..c270b3b --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt @@ -0,0 +1,882 @@ +package space.nstart.pcp.pcp_request_service.service + +import ballistics.Ballistics +import ballistics.orbitalPoints.timeStepper.RungeStepper +import ballistics.types.BallisticsError +import ballistics.types.EarthType +import ballistics.types.FleghtLineSector +import ballistics.types.InitialConditions +import ballistics.types.KeplerParams +import ballistics.types.OPKatObj +import ballistics.types.OrbitalPoint +import ballistics.types.PPI +import ballistics.types.PointViewParams +import ballistics.types.TLE +import ballistics.types.VisibilityParametersZRV +import ballistics.types.ZRV +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import ballistics.utils.toDateTime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import space.nstart.pcp.pcp_request_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_request_service.entity.AscNodeEntity +import space.nstart.pcp.pcp_request_service.entity.EarthCellViewsEntity +import space.nstart.pcp.pcp_request_service.entity.EarthCoverageEntity +import space.nstart.pcp.pcp_request_service.entity.FlightLineEntity +import space.nstart.pcp.pcp_request_service.entity.PDCMEntity +import space.nstart.pcp.pcp_request_service.model.SquareViewParamsModel +import space.nstart.pcp.pcp_request_service.repository.AscNodeRepository +import space.nstart.pcp.pcp_request_service.repository.EarthCellViewRepository +import space.nstart.pcp.pcp_request_service.repository.EarthCoverageRepository +import space.nstart.pcp.pcp_request_service.repository.FlightLineRepository +import space.nstart.pcp.pcp_request_service.repository.PDCMRepository +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.ObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamsWithPointsDTO +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.TargetPositionDTO +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import kotlin.collections.forEach +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.log +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.truncate + + +@Service +class SatelliteService { + + + @Value("\${settings.calculation-interval:7}") + val intervalForward : Int = 7 + + @Autowired + private lateinit var om: ObjectMapper + + private val logger : Logger = LoggerFactory.getLogger(this::class.java) + + @Autowired + private lateinit var pdcmRepository: PDCMRepository + @Autowired + private lateinit var ascNodeRepository: AscNodeRepository + @Autowired + private lateinit var flightLineRepository: FlightLineRepository + @Autowired + private lateinit var earthCoverageRepository: EarthCoverageRepository + @Autowired + private lateinit var earthCellViewRepository: EarthCellViewRepository + + + val astro = AstronomerJ2000(EarthType.PZ90d02) + + @KafkaListener(id = "updatedIcFilter", topics = ["\${spring.kafka.template.default-topic}"], groupId = "\${spring.kafka.consumer.group-id}", filter = "updatedIcFilter") + fun listener2(data: String?) { + + val tleMessage: TLEDTO = + try { + val rootNode = om.readTree(data) + val payloadNode = rootNode["data"] ?: throw IllegalArgumentException("В сообщении отсутствует поле data") + om.treeToValue(payloadNode, TLEDTO::class.java) + } + catch (e: Exception) { + logger.error("Ошибка парсинга сообщения от каталога: ${e.message}") + return + } + + logger.info("Полученно сообщение об изменении TLE [{}]", tleMessage) + + CoroutineScope(Dispatchers.Default).launch { + resieveTLE(tleMessage) + } + + logger.info("Выход из KafkaListener") + } + + suspend fun resieveTLE(tle: TLEDTO) { + val bal = Ballistics() + val params = bal.getTLEParams(TLE(tle.first, tle.second), tle.header ?: "") + + logger.info("Начало расчета баллистики по TLE noradId = ${params.noradId}") + + bal.rollMax = (45.0 * PI / 180.0) + bal.rollMin = (20.0 * PI / 180.0) + + var br = bal.calculateOrbPoints( + TLE(tle1 = tle.first, tle.second), + 86400.0 * intervalForward + ) + if (br != BallisticsError.OK) { + logger.warn("Ошибка при расчете ПДЦМ для noradId=${params.noradId}. Код ошибки: $br") + return + } + + pdcmRepository.deleteBySatId(params.noradId) + pdcmRepository.saveAll(bal.points.map { p -> PDCMEntity(p, params.noradId) }) + logger.info("Точки орбиты КА ${params.noradId} сохранены в БД") + + ascNodeRepository.deleteBySatId(params.noradId) + ascNodeRepository.saveAll(bal.revolutions.map { AscNodeEntity(it, params.noradId) }) + logger.info("ВУЗы орбиты КА ${params.noradId} сохранены в БД") + + val tInt = bal.points.first().t + br = bal.calculateFlightLine(tInt, tInt + 86400 * intervalForward) + if (br != BallisticsError.OK) { + logger.warn("Ошибка при расчете ТП и ПО для ka=${params.noradId}. Код ошибки: $br") + return + } + flightLineRepository.deleteBySatId(params.noradId) + flightLineRepository.saveAll(bal.flightLine.map { FlightLineEntity(it, params.noradId) }) + + logger.info("Параметры трассы полеты и полосы обзора КА ${params.noradId} сохранены в БД") + + + earthCoverageRepository.deleteBySatId(params.noradId) + earthCoverageRepository.saveAll(bal.coverings.flatMap { (k,v) -> mergeSectors(v).map { EarthCoverageEntity(it, k , params.noradId) } }) + logger.info("Параметры покрытия земной поверхности КА ${params.noradId} сохранены в БД") + + prepareEarthCovering(bal, params.noradId) + + bal.clear() + logger.info("Расчет для КА ${params.noradId} завершился успешно") + } + + + private fun InitialConditionsDTO.toIC() = InitialConditions( + OrbitalPoint( + t = fromDateTime(orbPoint.time), + vit = orbPoint.revolution.toInt(), + r = Vector3D(orbPoint.x,orbPoint.y,orbPoint.z), + v = Vector3D(orbPoint.vx,orbPoint.vy,orbPoint.vz) + ), + f81 = f81, + sBall = sBall + ) + + suspend fun resieveRV(rv: SatelliteICDTO) { + val bal = Ballistics() + + logger.info("Начало расчета баллистики по RV id = ${rv.satelliteId}") + + bal.rollMax = (45.0 * PI / 180.0) + bal.rollMin = (20.0 * PI / 180.0) + + var br = bal.calculateOrbPoints( + rv.ic.toIC(), + fromDateTime(rv.ic.orbPoint.time), + fromDateTime(rv.ic.orbPoint.time)+ 86400 * intervalForward + + ) + if (br != BallisticsError.OK) { + logger.warn("Ошибка при расчете ПДЦМ для noradId=${rv.satelliteId}. Код ошибки: $br") + return + } + + pdcmRepository.deleteBySatId(rv.satelliteId) + pdcmRepository.saveAll(bal.points.map { p -> PDCMEntity(p, rv.satelliteId) }) + logger.info("Точки орбиты КА ${rv.satelliteId} сохранены в БД") + + ascNodeRepository.deleteBySatId(rv.satelliteId) + ascNodeRepository.saveAll(bal.revolutions.map { AscNodeEntity(it, rv.satelliteId) }) + logger.info("ВУЗы орбиты КА ${rv.satelliteId} сохранены в БД") + + val tInt = bal.points.first().t + br = bal.calculateFlightLine(tInt, tInt + 86400 * intervalForward) + if (br != BallisticsError.OK) { + logger.warn("Ошибка при расчете ТП и ПО для ka=${rv.satelliteId}. Код ошибки: $br") + return + } + flightLineRepository.deleteBySatId(rv.satelliteId) + flightLineRepository.saveAll(bal.flightLine.map { FlightLineEntity(it, rv.satelliteId) }) + + logger.info("Параметры трассы полеты и полосы обзора КА ${rv.satelliteId} сохранены в БД") + + + earthCoverageRepository.deleteBySatId(rv.satelliteId) + earthCoverageRepository.saveAll(bal.coverings.flatMap { (k,v) -> mergeSectors(v).map { EarthCoverageEntity(it, k , rv.satelliteId) } }) + logger.info("Параметры покрытия земной поверхности КА ${rv.satelliteId} сохранены в БД") + + prepareEarthCovering(bal, rv.satelliteId) + + bal.clear() + logger.info("Расчет для КА ${rv.satelliteId} завершился успешно") + } + + + fun mergeSectors(sectors: List): List { + if (sectors.isEmpty()) return emptyList() + + val sorted = sectors.sortedBy { it.tStart } + val result = mutableListOf() + var current = sorted[0] + + for (i in 1 until sorted.size) { + val next = sorted[i] + if (abs(next.tStart - current.tStop) < 2) { + // Объединяем: расширяем tStop текущего сектора + current = FleghtLineSector(current.tStart, maxOf(current.tStop, next.tStop)) + } else { + result.add(current) + current = next + } + } + result.add(current) + + return result + } + + + fun clear(id : Long): SatelliteBallisticsDeletionSummary { + val pdcmDeleted = pdcmRepository.deleteBySatId(id) + val ascNodesDeleted = ascNodeRepository.deleteBySatId(id) + val flightLinesDeleted = flightLineRepository.deleteBySatId(id) + val earthCoverageDeleted = earthCoverageRepository.deleteBySatId(id) + val earthCellViewsDeleted = earthCellViewRepository.deleteBySatId(id) + val summary = SatelliteBallisticsDeletionSummary( + satelliteId = id, + pdcmDeleted = pdcmDeleted, + ascNodesDeleted = ascNodesDeleted, + flightLinesDeleted = flightLinesDeleted, + earthCoverageDeleted = earthCoverageDeleted, + earthCellViewsDeleted = earthCellViewsDeleted + ) + logger.info( + "Deleted ballistics data for satellite: satelliteId={}, pdcm={}, ascNodes={}, flightLines={}, earthCoverage={}, earthCellViews={}", + summary.satelliteId, + summary.pdcmDeleted, + summary.ascNodesDeleted, + summary.flightLinesDeleted, + summary.earthCoverageDeleted, + summary.earthCellViewsDeleted + ) + return summary + } + + fun exactTime(satId : Long, request : ExactTimePositionRequestDTO) = Flux.create { + sink -> + logger.info("Запрос расчета выхода на заданное время для КА $satId ") + val points = pdcmRepository.findBySatelliteIdAndTimeBetween( + satId, + request.time.minusSeconds(100), + request.timeStop?.plusSeconds(100) ?: request.time.plusSeconds(100) + ) + if (points.isEmpty()) + throw CustomErrorException("Нет точек орбиты для КА $satId на интервале ${request.time} - ${request.timeStop ?: request.time}") + val stepper = RungeStepper(points.map { p -> p.toOrbitalPoint() }.toMutableList(), EarthType.PZ90d02) + var t = request.time + val tk = request.timeStop ?: t.plusNanos(5) + var step = request.stepMs ?: 125 + if (step < 1) + step = 125 + while (t <= tk) { + + val op = stepper.calculate(fromDateTime(t)) + if (op == null) { + logger.error("Ошибка расчета выхода на заданное время $t") + throw CustomValidationException("Внутренняя ошибка расчета выхода на заданное время") + } + + sink.next( OrbPointDTO( + toDateTime(op.t), + op.vit.toLong(), + op.v.x, + op.v.y, + op.v.z, + op.r.x, + op.r.y, + op.r.z) + ) + t = t.plusNanos(step * 1000000) + } + if (request.timeStop != null && t.minusNanos(step * 1000000) < tk) { + val op = stepper.calculate(fromDateTime(tk)) + if (op == null) { + logger.error("Ошибка расчета выхода на заданное время $t") + throw CustomValidationException("Внутренняя ошибка расчета выхода на заданное время") + } + + sink.next(OrbPointDTO( + toDateTime(op.t), + op.vit.toLong(), + op.v.x, + op.v.y, + op.v.z, + op.r.x, + op.r.y, + op.r.z) + ) + } + + sink.complete() + } + + + + fun getCellCovering(num : Long) : List{ + return earthCellViewRepository.findByCellNum(num).map { view -> + val points = pdcmRepository.findBySatelliteIdAndTimeBetween( + view.satelliteId, + view.timeBegin.minusSeconds(100), + view.timeEnd.plusSeconds(100) + ) + SquareViewParamsWithPointsDTO( + view.toDTO(), + points.map { it.toDTO() } + ) + } + } + + + fun mplPoint(req : ObjViewRequestDTO) : Flux{ + + return Flux.fromIterable( + req.satellites + .flatMap { sat -> + val bal = Ballistics() + bal.setOrbitalPoints( + loadOrbitalPoints(sat, req.timeStart, req.timeStop), + EarthType.PZ90d02 + ) + + bal.rollMin = 0.0 + bal.rollMax = 45.0 / 180 * PI + + val indexes = mkCovIndexes(listOf(req.obj.position?: PositionDTO())) + val sectors = loadEarthCoverageSectors(sat, req.timeStart, req.timeStop, indexes) + + bal.setEarthCoverage( + sectors + ) + + val t1 = req.timeStart + val t2 = req.timeStop + bal.sunAngleMin = req.sunAngleMin?.let { it / 180 * PI } ?: (-PI / 2.0) + + val r = bal.calculateMPL( + fromDateTime(t1), + fromDateTime(t2), + listOf(OPKatObj(0,0,"",0, + (req.obj.position?.lat?:0.0) * PI / 180, + (req.obj.position?.long?:0.0) * PI / 180, + req.obj.position?.height?:0.0, + 0.0, 0.0, 0.0 + ) + ) + ) + if (r != BallisticsError.OK) { + logger.error("Ошибка при расчете параметров наблюдения объекта (${req.obj}) для КА $sat. Код ошибки : $r") + listOf() + } else + bal.mpl.map { PointViewParamDTO( + noradId = sat, + objectId = req.obj.id?:"", + time = toDateTime(it.traverz), + revolution = it.vit.toLong(), + gamma = it.orientation.kren * 180 / PI, + sunAngle = it.sunAngle * 180 / PI, + range = it.range, + revSign = if (it.pv ==0 ) RevolutionSign.ASC else RevolutionSign.DESC + ) } + + } + ) + } + + + fun mplSquare(req : ObjViewRequestDTO) : Flux{ + + logger.info("Запрос МПЛ по площадному объекту") + return Flux.fromIterable( + req.satellites + .flatMap { sat -> + val bal = Ballistics() + + bal.setOrbitalPoints( + loadOrbitalPoints(sat, req.timeStart, req.timeStop), + EarthType.PZ90d02 + ) + + bal.rollMin = 0.0 + bal.rollMax = 45.0 / 180 * PI + + val contourService = ContourClipService() + val contour = contourService.getObjContour(req.obj) + if (contour == null || contour.count() < 3) { + logger.error("Контур площадного объекта пуст или содержит меньше 3-х точек") + listOf() + } + else { + val indexes = mkCovIndexes(contour) + val sectors = loadEarthCoverageSectors(sat, req.timeStart, req.timeStop, indexes) + + bal.setEarthCoverage( + sectors + ) + val t1 = req.timeStart + val t2 = req.timeStop + bal.sunAngleMin = req.sunAngleMin?.let { it / 180 * PI } ?: (-PI / 2.0) + + + val r = bal.calculateMPL( + fromDateTime(t1), + fromDateTime(t2), + contourService.splitPolySides(contour.map { p -> + PositionDTO( + p.lat / 180 * PI, + p.long / 180 * PI, + p.height + ) + }) + ) + if (r != BallisticsError.OK) { + logger.error("Ошибка при расчете параметров наблюдения площадного объекта (${req.obj}) для КА $sat. Код ошибки : $r") + listOf() + } else + squareViewFromMPL(sat, bal.mpl, req.obj.id ?: "") + } + } + ).map { square -> square.toDTO() } + } + + + private fun squareViewFromMPL(id : Long, mpl: Iterable, n: String): Iterable{ + + val svp = mutableListOf() + val vp = SquareViewParamsModel( + tN = -1.0, + tK = 0.0, + objID = n, + id = id + ) + sortPointViews(mpl).forEach { m -> + if (m.traverz - vp.tK > 60.0*3) { + // начало нового интервала наблюдения! Надо сохранить старый, если такой есть. + if (vp.tN > 0) + svp.add(vp.clone()) + + vp.tN = m.traverz + vp.vitN = m.vit + vp.tK = m.traverz + vp.vitK = m.vit + vp.pvN = if (m.pv == 0) RevolutionSign.ASC else RevolutionSign.DESC + vp.pvK = if (m.pv == 0) RevolutionSign.ASC else RevolutionSign.DESC + vp.krenMin = m.orientation.kren + vp.krenMax = m.orientation.kren + } else { + vp.tK = m.traverz + vp.vitK = m.vit + + vp.pvK = if (m.pv == 0) RevolutionSign.ASC else RevolutionSign.DESC + if (vp.krenMax < m.orientation.kren) + vp.krenMax = m.orientation.kren + + if (vp.krenMin > m.orientation.kren) + vp.krenMin = m.orientation.kren + } + } + if (vp.tN > 0) + svp.add(vp.clone()) + return svp + } + + internal fun sortPointViews(mpl: Iterable): List = + mpl.sortedWith( + compareBy( + { it.traverz }, + { it.vit }, + { it.pointNumb }, + { it.pv }, + { it.orientation.kren } + ) + ) + + private fun loadOrbitalPoints(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime) = + pdcmRepository.findBySatelliteIdAndTimeBetween(satelliteId, timeStart, timeStop) + .sortedWith( + compareBy( + { it.time }, + { it.revolution }, + { it.id ?: Long.MAX_VALUE } + ) + ) + .map { it.toOrbitalPoint() } + + private fun loadEarthCoverageSectors( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + cellIndexes: List + ): Map> = + earthCoverageRepository.findBySatelliteIdAndTStartGreaterThanAndTStopLessThanAndCellNumberIn( + satelliteId, + timeStart, + timeStop, + cellIndexes + ) + .sortedWith( + compareBy( + { it.cellNumber }, + { it.tStart }, + { it.tStop }, + { it.id ?: Long.MAX_VALUE } + ) + ) + .groupBy { it.cellNumber.toInt() } + .entries + .sortedBy { it.key } + .associateTo(linkedMapOf()) { (cellNumber, views) -> + cellNumber to views.map { view -> + FleghtLineSector( + fromDateTime(view.tStart), + fromDateTime(view.tStop) + ) + }.toMutableList() + } + + + + + fun getBInd(b: Double): Long { + var b2: Double = b * 180.0 / PI + b2 = 90.0 - b2 + var k: Long = truncate(b2 / 2.0).toLong() + if (k > 89) k = 89 + return k + } + + fun getLInd(l2: Double): Long { + var l: Double = l2 * 180.0 / PI + if (l < 0) l += 360.0 + var k: Long = truncate(l / 2.0).toLong() + if (k > 179) k = 179 + return k + } + + fun getInd( + b: Double, + l: Double, + ): Long { + return getBInd(b) * 180 + getLInd(l) + } + + + fun mkCovIndexes(contour : Iterable) : List{ + val indexes = mutableListOf() + var bMin = 100.0 + var bMax = -100.0 + var lMin = 370.0 + var lMax = -370.0 + + contour.forEach { c -> + val b = c.lat + val l = c.long + + if (b < bMin) bMin = b + if (b > bMax) bMax = b + if (l < lMin) lMin = l + if (l > lMax) lMax = l + } + + while (bMin < bMax + 0.6){ + + var l = lMin + while (l < lMax + 0.6){ + val ind = getInd(bMin * PI / 180, l * PI / 180) + if (!indexes.contains(ind)) + indexes.add(ind) + l += 0.5 + } + + bMin += 0.5 + } + return indexes + } + + + + + + + fun getBInd2(b: Double): Long { + var b2: Double = b * 180.0 / PI + b2 = 90.0 - b2 + var k: Long = truncate(b2).toLong() + if (k > 180) k = 180 + return k + } + + fun getLInd2(l2: Double): Long { + var l: Double = l2 * 180.0 / PI + if (l < 0) l += 360.0 + var k: Long = truncate(l).toLong() + if (k > 359) k = 359 + return k + } + + fun getInd2( + b: Double, + l: Double, + ): Long { + return getBInd2(b) * 360 + getLInd2(l) + } + + + + + fun prepareEarthCovering(bal : Ballistics, satId : Long){ + + logger.info("Начало расчета покрытия ячеек земной поверхности для КА $satId") + + earthCellViewRepository.deleteBySatId(satId) + logger.info("Функционал не используется. Старые результаты удалены.") + return + +// val contourService = ContourClipService() +// val step = 1.0 +// +// bal.sunAngleMin = -PI/2 +// +// val num : Long = 1 +// var b = -90 + step +// while (b <= 90){ +// var l = 0.0 +// val line = mutableListOf() +// while (l <= 360){ +// val cellInd = getInd2(b * PI / 180.0,l* PI / 180.0) +// val t1 = bal.points.first().t +// val t2 = bal.points.last().t +// val cont : String = "POLYGON (($l $b, ${l-1} ${b}, ${l-1} ${b-1}, $l ${b-1}, $l $b))" +// val contour = contourService.getObjContour(ObjDTO(contourWKT = cont)) +// if (contour == null || contour.count() < 3) { +// logger.error("Контур площадного объекта пуст или содержит меньше 3-х точек") +// continue +// } +// +// val objs = contourService.splitPolySides(contour.map { p -> +// PositionDTO( +// p.lat / 180 * PI, +// p.long / 180 * PI, +// p.height +// ) +// } +// ) +// val r = bal.calculateMPL( +// t1, +// t2, +// objs +// +// ) +// val views = +// if (r != BallisticsError.OK) { +// logger.error("Ошибка при расчете параметров наблюдения площадного объекта (${cellInd}) для КА $satId. Код ошибки : $r") +// listOf() +// } else +// squareViewFromMPL(satId, bal.mpl, "") +// +// for (view in views) { +// line.add( +// EarthCellViewsEntity( +// null, +// satId, +// cellInd, +// view.vitN.toLong(), +// toDateTime(view.tN), +// if(view.pvN == RevolutionSign.ASC) 0 else 1, +// view.vitK.toLong(), +// toDateTime(view.tK), +// if(view.pvK == RevolutionSign.ASC) 1 else 0, +// view.krenMin * 180 / PI, +// view.krenMax * 180 / PI, +// cont +// ) +// ) +// } +// l += step +// if (line.isEmpty()) { +// logger.info("нет наблюдений ячейки $cont") +// } +// earthCellViewRepository.saveAll(line) +// line.clear() +// } +// b += step +// } +// logger.info("Конец расчета покрытия ячеек земной поверхности для КА $satId") + } + + fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) = + pdcmRepository.findBySatelliteIdAndTimeBetween( + id, + tn?: LocalDateTime.now(), + tk?: LocalDateTime.now().plusDays(1)) + .map { it.toDTO() + } + + fun getOrbitAvailability(time: LocalDateTime?): List = + if (time == null) { + pdcmRepository.findSatelliteOrbitAvailability() + } else { + pdcmRepository.findSatelliteOrbitAvailabilityAtTime(time) + } + + fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) = + flightLineRepository.findBySatelliteIdAndTimeBetween(id, + tn?: LocalDateTime.now(), + tk?: LocalDateTime.now().plusDays(1) + ) + .map { it.toDTO() } + + + + fun getRVA(id : Long, tn : LocalDateTime, tk : LocalDateTime) : List { + + val bal = Ballistics() + + val points = pdcmRepository.findBySatelliteIdAndTimeBetween(id, + tn, + tk).map { it.toOrbitalPoint() } + + if (points.isEmpty()) + throw CustomErrorException("Нет ПДЦМ для КА $id на интервале времени $tn - $tk") + + bal.setOrbitalPoints(points, + EarthType.PZ90d02) + + val tstart = points.first().t + val tstop = points.last().t + + bal.rollMin = 0.0 + bal.rollMax = 45.0 / 180 * PI + + + val r = bal.calculateZRV( + listOf(PPI(1,1,0.35, 0.45, 100.0, 0.0, PI/2)), + tstart, + tstop + ) + if (r != BallisticsError.OK) { + logger.error("Ошибка при расчете параметров ЗРВ : $r") + return listOf() + } else + return bal.zrv.map { mkRVA(it, id)} + + } + + fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO( + noradId = id, + stationId = p.ppi.toLong(), + revolution = p.vit.toLong(), + onStart = mkTP(p.zoneIn), + onMaximum = mkTP(p.zoneMax), + onStop = mkTP(p.zoneOut) + ) + fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO( + toDateTime(p.t), + p.elevation * 180 / PI, + p.azimuth * 180 / PI, + p.range + ) + + + + fun calculateKeplerParams(point: OrbitalPoint): KeplerParams { + val res = KeplerParams() + + val aL00 = 6.25648106E+7 + val ask = astro.grinvToASK(point) + + val r = sqrt(sqr(ask.r.x) + sqr(ask.r.y) + sqr(ask.r.z)) + val v = sqrt(sqr(ask.v.x) + sqr(ask.v.y) + sqr(ask.v.z)) + + val mu = astro.earth.fM + val k = (r * sqr(v)) / mu + + val sinO = (ask.r.x * ask.v.x + ask.r.y * ask.v.y + ask.r.z * ask.v.z) / (r * v) + val cosO = sqrt(1 - sqr(sinO)) + + val c1 = ask.r.y * ask.v.z - ask.r.z * ask.v.y + val c2 = ask.r.z * ask.v.x - ask.r.x * ask.v.z + val c3 = ask.r.x * ask.v.y - ask.r.y * ask.v.x + val c = sqrt(sqr(c1) + sqr(c2) + sqr(c3)) + + res.ael = r / (2 - k) + val lambda = (1 / res.ael) * sqrt(mu / res.ael) + + res.t = (2 * PI * res.ael * sqrt(res.ael)) / sqrt(astro.earth.middleRadius * aL00) + res.e = sqrt(1 - k * (2 - k) * (1 - sinO * sinO)) + res.v = atan((k * sinO * cosO) / (k * sqr(cosO) - 1)) + res.u = atan2((ask.r.z * c), (ask.r.y * c1 - ask.r.x * c2)) + res.eA = atan((sqrt(1 - sqr(res.e)) * k * sinO * cosO) / (sqr(res.e) + (k * sqr(cosO) - 1))) + res.tau = ask.t + ((res.e * sin(res.eA) - res.eA) / lambda) + res.nakl = 0.5 * PI - asin(c3 / c) + res.omegab = atan2(c1, -c2) + res.omegam = res.u - res.v + res.o = asin(sinO) + res.rA = res.ael * (1 + res.e) + res.rP = res.ael * (1 - res.e) + res.dmv = point.t + + if (res.omegam < 0) res.omegam += 2 * PI + if (res.tau < 0) res.tau += 1 + + return res + } + + + + fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List { + + return ascNodeRepository.findBySatelliteIdAndTimeBetween( + id, + tn ?: LocalDateTime.now(), + tk ?: LocalDateTime.now().plusDays(1) + ) + .map { + val keps = calculateKeplerParams( + OrbitalPoint( + fromDateTime(it.time), + 1, + Vector3D(it.x,it.y,it.z), + Vector3D(it.vx,it.vy,it.vz) + )) + it.toDTO(keps, astro.earth.ekvRadius) + } + } +} + +data class SatelliteBallisticsDeletionSummary( + val satelliteId: Long, + val pdcmDeleted: Int, + val ascNodesDeleted: Int, + val flightLinesDeleted: Int, + 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 new file mode 100644 index 0000000..e44b004 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/TLEService.kt @@ -0,0 +1,86 @@ +package space.nstart.pcp.pcp_request_service.service + +import ballistics.Ballistics +import ballistics.types.BallisticsError +import ballistics.types.PPI +import ballistics.types.TLE +import ballistics.types.TLEParams +import ballistics.types.VisibilityParametersZRV +import ballistics.types.ZRV +import ballistics.utils.fromDateTime +import ballistics.utils.toDateTime +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_request_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException +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.TargetPositionDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO +import kotlin.math.PI + + +@Service +class TLEService() { + + fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO( + toDateTime(p.t), + p.elevation * 180 / PI, + p.azimuth * 180 / PI, + p.range + ) + + fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO( + noradId = id, + stationId = p.ppi.toLong(), + revolution = p.vit.toLong(), + onStart = mkTP(p.zoneIn), + onMaximum = mkTP(p.zoneMax), + onStop = mkTP(p.zoneOut) + ) + + + fun parseTLE(tle: TLEDTO) : TLEParams{ + val bal = Ballistics() + return bal.getTLEParams(TLE(tle.first, tle.second), tle.header?:"empty") + } + + + + fun rva(req : TleRvaRequestDTO) : Iterable { + + val bal = Ballistics() + var tleParams : TLEParams + try { + tleParams = bal.getTLEParams(TLE(req.tle.first, req.tle.second)) + if (req.timeStart >= req.timeStop || req.timeStart < tleParams.time) + throw CustomValidationException("Ошибка задания интервала расчета") + }catch (ex : Exception){ + throw CustomValidationException("Ошибка формата TLE : ${ex.message}") + } + val duration = fromDateTime(req.timeStop) - fromDateTime(req.timeStart) + val r = bal.calculateOrbPoints(TLE(req.tle.first, req.tle.second), duration) + + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета ПДЦМ : $r") + + val rz = bal.calculateZRV( + listOf(PPI( + req.station.number.toInt(), + 0, + req.station.position.lat * PI / 180.0, + req.station.position.long * PI / 180.0, + req.station.position.height, + req.station.elevationMin * PI / 180.0, + req.station.elevationMax * PI / 180.0) + ), + fromDateTime(req.timeStart), + fromDateTime(req.timeStop) + ) + + if (rz != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета зон радио-видимости : $rz") + + return bal.zrv.map { mkRVA(it, tleParams.noradId) } + } +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/utils/ContourClipService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/utils/ContourClipService.kt new file mode 100644 index 0000000..00aa6a7 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/utils/ContourClipService.kt @@ -0,0 +1,176 @@ +package space.nstart.pcp.pcp_request_service.utils + +import ballistics.types.OPKatObj +import clipper.IntPoint +import clipper.Path +import clipper.containsPoint +import org.locationtech.jts.io.WKTReader +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sqrt + + +class ContourClipService { + + + + fun prceWKT(wkt : String) : Iterable?{ + try { + val contour = mutableListOf() + val reader = WKTReader() + val geom = reader.read(wkt) + for (c in geom.coordinates) + contour.add(PositionDTO(c.y, c.x, c.z)) + if (contour.size < 3) + return null + return contour + } catch (ex : Exception){ + throw CustomValidationException("Ошибка пазбора WKT-формата") + } + } + + fun getObjContour(obj : ObjDTO) : Iterable?{ + obj.contourWKT?.let { return prceWKT(obj.contourWKT!!) } + return null + } + + + fun splitPolySides(poly : Iterable, internal : Boolean = true) : Iterable{ + +// return poly.map { p1 -> +// OPKatObj( +// 1, 1, "", 1, p1.lat, p1.long, +// 0.0, 0.0, PI / 2, -PI / 2 +// ) +// } + val res = mutableListOf() + for (i in 1 ..poly.count()-1){ + res.addAll(splitLine(poly.elementAt(i-1), poly.elementAt(i))) + } +// if (internal) + res.addAll(splitPoly(poly, 2.0 * PI / 180.0)) + return res + } + + private fun splitLine(p1 : PositionDTO, p2 : PositionDTO) : Iterable{ + var x1 = p1.long + var x2 = p2.long + if (abs(x1 - x2) > PI){ + if (x1 < x2) + x1 += 2 * PI + else + x2 = 2 * PI + } + if (x1 > x2){ + val buf = x1 + x1 = x2 + x2 = buf + } + var y1 = p1.lat + var y2 = p2.lat + if (y1 > y2){ + val buf = y1 + y1 = y2 + y2 = buf + } + val dx = x2 - x1 + val dy = y2 - y1 + val dl = sqrt((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1)) + val res = mutableListOf() + res.add(OPKatObj(1, 1, "", 1, p1.lat, p1.long, 0.0, 0.0, PI / 2, -PI / 2)) + if (dl > 0.01) { + res.add(OPKatObj(1, 1, "", 2, p2.lat, p2.long, 0.0, 0.0, PI / 2, -PI / 2)) + var pn = 3 + val step = 0.1 * PI / 180 + if (dx > dy) { + var x = x1 + step + val k = dy / dx + val b = y2 - k * x2 + (0..(dx / step).toInt()).forEach { i -> + if (x < x2) { + val y = k * x + b + res.add(OPKatObj(1, 1, "", pn, y, x, 0.0, 0.0, PI / 2, -PI / 2)) + ++pn + } + x += step + } + } else { + if (dx < 0.000001) { + var y = y1 + step + (0..(dy / step).toInt()).forEach { i -> + if (y < y2) { + res.add(OPKatObj(1, 1, "", pn, y, x1, 0.0, 0.0, PI / 2, -PI / 2)) + ++pn + } + y += step + } + } else { + var y = y1 + step + val k = dy / dx + val b = y2 - k * x2 + (0..(dy / step).toInt()).forEach { i -> + if (y < y2) { + val x = (y - b) / k + res.add(OPKatObj(1, 1, "", pn, y, x, 0.0, 0.0, PI / 2, -PI / 2)) + ++pn + } + y += step + } + } + } + } + return res + } + + private fun splitPoly(poly : Iterable, step : Double? = null) : Iterable { + val res = mutableListOf() + val path = Path() + val clipVal = 1000000 + var bMin = poly.minOf { it.lat } + val bMax = poly.maxOf { it.lat } + var lMin = poly.minOf { it.long } + var lMax = poly.maxOf { it.long } + for (p in poly) { + path.add(IntPoint((p.lat * clipVal).toLong(), (p.long * clipVal).toLong())) + if (step != null) + res.add(OPKatObj(1, 1, "", 1, p.lat, p.long, 0.0, 0.0, PI / 2, -PI / 2)) + } + path.add(path.first()) + if ((lMin < 0 && lMax > 0) && (lMax - lMin > PI)) { + val b = lMax + lMax = lMin + PI * 2 + lMin = b + } + if (lMax - lMin > PI) { + val b = lMax + lMax = lMin + lMin = b - 2 * PI + } + var stepL : Double + var stepB : Double + if (step != null){ + stepL = step + stepB = step + }else { + stepL = (lMax - lMin) / 10 + if (stepL > 0.1 * PI / 180) + stepL = 0.1 * PI / 180 + stepB = (bMax - bMin) / 10 + if (stepB > 0.1 * PI / 180) + stepB = 0.1 * PI / 180 + } + while (bMin <= bMax) { + var l = lMin + while (l <= lMax) { + if (path.containsPoint(IntPoint( (bMin * clipVal).toLong(), (l * clipVal).toLong())) == 1) + res.add(OPKatObj(1, 1, "", 1, bMin, l, 0.0, 0.0, PI / 2, -PI / 2)) + l += stepL + } + bMin += stepB + } + return res + } +} \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/resources/application.yaml b/services/pcp-ballistics-service/src/main/resources/application.yaml new file mode 100644 index 0000000..973f1d2 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-ballistics-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} diff --git a/services/pcp-ballistics-service/src/main/resources/db/migration/V1__create_points.sql b/services/pcp-ballistics-service/src/main/resources/db/migration/V1__create_points.sql new file mode 100644 index 0000000..8826638 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/resources/db/migration/V1__create_points.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS pdcm ( + pdcm_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + t TIMESTAMP NOT NULL, + revolution BIGINT NOT NULL, + x DOUBLE PRECISION, + y DOUBLE PRECISION, + z DOUBLE PRECISION, + vx DOUBLE PRECISION, + vy DOUBLE PRECISION, + vz DOUBLE PRECISION +); + +CREATE INDEX idx_pdcm_satellite_id ON pdcm(satellite_id); +CREATE INDEX idx_pdcm_t ON pdcm(t); +CREATE INDEX idx_pdcm_revolution ON pdcm(revolution); \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/resources/db/migration/V2__create_asc_node_fl_cov.sql b/services/pcp-ballistics-service/src/main/resources/db/migration/V2__create_asc_node_fl_cov.sql new file mode 100644 index 0000000..8827678 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/resources/db/migration/V2__create_asc_node_fl_cov.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS asc_node ( + asc_node_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + t TIMESTAMP NOT NULL, + revolution BIGINT NOT NULL, + longitude DOUBLE PRECISION, + height DOUBLE PRECISION, + x DOUBLE PRECISION, + y DOUBLE PRECISION, + z DOUBLE PRECISION, + vx DOUBLE PRECISION, + vy DOUBLE PRECISION, + vz DOUBLE PRECISION +); +CREATE INDEX idx_asc_node_satellite_id ON asc_node(satellite_id); +CREATE INDEX idx_asc_node_t ON asc_node(t); +CREATE INDEX idx_asc_node_revolution ON asc_node(revolution); + + +CREATE TABLE IF NOT EXISTS flight_line ( + flight_line_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + t TIMESTAMP NOT NULL, + revolution BIGINT NOT NULL, + revolution_sign VARCHAR(4) NOT NULL CHECK (revolution_sign IN ('ASC', 'DESC')), + longitude DOUBLE PRECISION, + latitude DOUBLE PRECISION, + longitude_outer_left DOUBLE PRECISION, + latitude_outer_left DOUBLE PRECISION, + longitude_inner_left DOUBLE PRECISION, + latitude_inner_left DOUBLE PRECISION, + longitude_inner_right DOUBLE PRECISION, + latitude_inner_right DOUBLE PRECISION, + longitude_outer_right DOUBLE PRECISION, + latitude_outer_right DOUBLE PRECISION +); +CREATE INDEX idx_flight_line_satellite_id ON flight_line(satellite_id); +CREATE INDEX idx_flight_line_t ON flight_line(t); +CREATE INDEX idx_flight_line_revolution ON flight_line(revolution); + + +CREATE TABLE IF NOT EXISTS earth_coverage ( + earth_coverage_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + cell_number BIGINT NOT NULL, + t_start TIMESTAMP NOT NULL, + t_stop TIMESTAMP NOT NULL +); +CREATE INDEX idx_earth_coverage_satellite_id ON earth_coverage(satellite_id); +CREATE INDEX idx_earth_coverage_t ON earth_coverage(t_start); +CREATE INDEX idx_earth_coverage_cell_number ON earth_coverage(cell_number); \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/main/resources/db/migration/V3__cells_covering.sql b/services/pcp-ballistics-service/src/main/resources/db/migration/V3__cells_covering.sql new file mode 100644 index 0000000..ad46b14 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/resources/db/migration/V3__cells_covering.sql @@ -0,0 +1,23 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + +CREATE TABLE IF NOT EXISTS cell_covering ( + cell_covering_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + cell_num BIGINT NOT NULL, + rev_begin BIGINT NOT NULL, + time_begin TIMESTAMP NOT NULL, + rev_sign_begin INT NOT NULL, + rev_end BIGINT NOT NULL, + time_end TIMESTAMP NOT NULL, + rev_sign_end INT NOT NULL, + roll_min DOUBLE PRECISION, + roll_max DOUBLE PRECISION, + contour_wkt TEXT NOT NULL +); + +ALTER TABLE cell_covering ADD COLUMN IF NOT EXISTS contour_geom geometry(Polygon, 4326) +GENERATED ALWAYS AS (ST_GeomFromText(contour_wkt)::geometry(Polygon, 4326)) STORED; + +CREATE INDEX idx_cell_covering_cell_num ON cell_covering(cell_num); +CREATE INDEX idx_cell_covering_satellite_id ON cell_covering(satellite_id); +CREATE INDEX idx_cell_covering_contour_geom ON cell_covering USING GIST (contour_geom); \ No newline at end of file diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplicationTests.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplicationTests.kt new file mode 100644 index 0000000..30e7d04 --- /dev/null +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplicationTests.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_request_service + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class PcpBallisticsServiceApplicationTests { + + @Test + fun contextLoads() { + } + +} diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListenerGroupIdTest.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListenerGroupIdTest.kt new file mode 100644 index 0000000..00c7109 --- /dev/null +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListenerGroupIdTest.kt @@ -0,0 +1,106 @@ +package space.nstart.pcp.pcp_request_service.message + +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SatelliteDeletedKafkaListenerGroupIdTest { + + private val listeners = mapOf( + "slots-service" to ListenerExpectation( + path = "services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedKafkaListener.kt", + groupId = "pcp-slots-service-satellite-deleted", + hasEventFilter = true + ), + "pcp-ballistics-service" to ListenerExpectation( + path = "services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListener.kt", + groupId = "pcp-ballistics-service-satellite-deleted", + hasEventFilter = true + ), + "tle-monitoring-service" to ListenerExpectation( + path = "services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/SatelliteDeletedKafkaListener.kt", + groupId = "pcp-tle-monitoring-service-satellite-deleted", + hasEventFilter = true + ), + "pcp-complex-mission-service" to ListenerExpectation( + path = "services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedKafkaListener.kt", + groupId = "pcp-complex-mission-service-satellite-deleted" + ), + "pcp-mission-planing-service" to ListenerExpectation( + path = "services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedKafkaListener.kt", + groupId = "pcp-mission-planing-service-satellite-deleted" + ), + "pcp-dynamic-plan-service" to ListenerExpectation( + path = "services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedKafkaListener.kt", + groupId = "pcp-dynamic-plan-service-satellite-deleted" + ) + ) + + @Test + fun `satellite deleted listeners use unique explicit consumer groups`() { + val resolvedGroupIds = listeners.mapValues { (_, expectation) -> + val source = projectRoot().resolve(expectation.path).readText() + val annotationBody = kafkaListenerAnnotationBody(source) + val groupReference = annotationParameter(annotationBody, "groupId") + assertNotNull(groupReference, "SatelliteDeletedEvent listener must define explicit groupId: ${expectation.path}") + assertEquals("SATELLITE_DELETED_CONSUMER_GROUP", groupReference) + assertTrue( + annotationBody.contains("""topics = ["\${'$'}{app.kafka.topics.satellites:pcp.satellites}"]"""), + "SatelliteDeletedEvent listener must keep satellite topic: ${expectation.path}" + ) + if (expectation.hasEventFilter) { + assertTrue( + annotationBody.contains("""filter = "satelliteDeletedFilter""""), + "SatelliteDeletedEvent listener must keep event filter: ${expectation.path}" + ) + } + + val groupId = consumerGroupConstantValue(source) + assertEquals(expectation.groupId, groupId) + groupId + } + + assertEquals( + resolvedGroupIds.size, + resolvedGroupIds.values.toSet().size, + "SatelliteDeletedEvent consumer groups must be unique per service: $resolvedGroupIds" + ) + } + + private fun kafkaListenerAnnotationBody(source: String): String { + val match = Regex("@KafkaListener\\((.*?)\\)", RegexOption.DOT_MATCHES_ALL).find(source) + assertNotNull(match, "SatelliteDeletedEvent listener must have @KafkaListener") + return match.groupValues[1] + } + + private fun annotationParameter(annotationBody: String, parameterName: String): String? = + Regex("""$parameterName\s*=\s*([^,\n)]+)""") + .find(annotationBody) + ?.groupValues + ?.get(1) + ?.trim() + + private fun consumerGroupConstantValue(source: String): String { + val match = Regex("""private const val SATELLITE_DELETED_CONSUMER_GROUP\s*=\s*"([^"]+)"""").find(source) + assertNotNull(match, "SatelliteDeletedEvent listener must declare SATELLITE_DELETED_CONSUMER_GROUP") + return match.groupValues[1] + } + + private fun projectRoot(): Path { + generateSequence(Path.of(System.getProperty("user.dir")).toAbsolutePath()) { it.parent } + .firstOrNull { Files.exists(it.resolve("settings.gradle.kts")) } + ?.let { return it } + + error("Cannot locate project root from ${System.getProperty("user.dir")}") + } + + private data class ListenerExpectation( + val path: String, + val groupId: String, + val hasEventFilter: Boolean = false + ) +} 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 new file mode 100644 index 0000000..0d712a7 --- /dev/null +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListenerTest.kt @@ -0,0 +1,96 @@ +package space.nstart.pcp.pcp_request_service.message + +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.SatelliteICDTO +import tools.jackson.databind.ObjectMapper + +class SatelliteIcRvKafkaListenerTest { + + private val objectMapper = ObjectMapper() + private val satelliteIcEventService = RecordingSatelliteIcEventService() + private val listener = SatelliteIcRvKafkaListener( + objectMapperProvider = SingleObjectProvider(objectMapper), + satelliteIcEventService = satelliteIcEventService + ) + + @Test + fun `listener parses ICRV placed event payload and delegates to service`() { + listener.consume( + """ + { + "type": "ICRVPlacedEvent", + "data": { + "satelliteId": 56756, + "ic": { + "orbPoint": { + "time": "2026-04-16T12:00:00", + "revolution": 42, + "vx": 1.0, + "vy": 2.0, + "vz": 3.0, + "x": 4.0, + "y": 5.0, + "z": 6.0 + }, + "sBall": 0.7, + "f81": 140.0 + } + } + } + """.trimIndent() + ) + + assertEquals(56756L, satelliteIcEventService.lastMessage?.satelliteId) + assertEquals(42L, satelliteIcEventService.lastMessage?.ic?.orbPoint?.revolution) + assertEquals(140.0, satelliteIcEventService.lastMessage?.ic?.f81) + } + + @Test + fun `listener substitutes default initial condition coefficients when payload contains nulls`() { + listener.consume( + """ + { + "type": "ICRVPlacedEvent", + "data": { + "satelliteId": 56756, + "ic": { + "orbPoint": { + "time": "2026-04-16T12:00:00", + "revolution": 42, + "vx": 1.0, + "vy": 2.0, + "vz": 3.0, + "x": 4.0, + "y": 5.0, + "z": 6.0 + }, + "sBall": null, + "f81": null + } + } + } + """.trimIndent() + ) + + assertEquals(0.0, satelliteIcEventService.lastMessage?.ic?.sBall) + assertEquals(147.8, satelliteIcEventService.lastMessage?.ic?.f81) + } + + private class RecordingSatelliteIcEventService : SatelliteIcEventService() { + var lastMessage: SatelliteICDTO? = null + + override fun handlePlacedIcRv(message: SatelliteICDTO) { + lastMessage = message + } + } + + private class SingleObjectProvider(private val value: T) : ObjectProvider { + override fun getObject(vararg args: Any?): T = value + override fun getIfAvailable(): T = value + override fun getIfUnique(): T = value + override fun getObject(): T = value + } +} 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 new file mode 100644 index 0000000..f77a7c6 --- /dev/null +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt @@ -0,0 +1,125 @@ +package space.nstart.pcp.pcp_request_service.repository + +import jakarta.persistence.EntityManager +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +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 java.time.LocalDateTime +import kotlin.test.assertEquals + +@SpringBootTest( + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.kafka.bootstrap-servers=false", + "spring.kafka.listener.auto-startup=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.url=jdbc:h2:mem:pdcm-repository-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect" + ] +) +@Transactional +class PDCMRepositoryTest { + + @Autowired + private lateinit var entityManager: EntityManager + + @Autowired + private lateinit var repository: PDCMRepository + + @Test + fun `find satellite orbit availability returns all satellites with points when time is null`() { + persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) + persistPoint(11L, LocalDateTime.of(2026, 4, 14, 12, 0)) + persistPoint(22L, LocalDateTime.of(2026, 4, 15, 9, 30)) + persistPoint(22L, LocalDateTime.of(2026, 4, 15, 18, 45)) + + val actual = repository.findSatelliteOrbitAvailability() + + assertEquals(2, actual.size) + assertEquals(11L, actual[0].satelliteId) + assertEquals(LocalDateTime.of(2026, 4, 14, 10, 0), actual[0].timeStart) + assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual[0].timeStop) + assertEquals(22L, actual[1].satelliteId) + assertEquals(LocalDateTime.of(2026, 4, 15, 9, 30), actual[1].timeStart) + assertEquals(LocalDateTime.of(2026, 4, 15, 18, 45), actual[1].timeStop) + } + + @Test + fun `find satellite orbit availability filters satellites whose interval contains requested time`() { + persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) + persistPoint(11L, LocalDateTime.of(2026, 4, 14, 12, 0)) + persistPoint(22L, LocalDateTime.of(2026, 4, 15, 9, 30)) + persistPoint(22L, LocalDateTime.of(2026, 4, 15, 18, 45)) + + val actual = repository.findSatelliteOrbitAvailabilityAtTime(LocalDateTime.of(2026, 4, 14, 11, 0)) + + assertEquals(1, actual.size) + assertEquals(11L, actual.single().satelliteId) + assertEquals(LocalDateTime.of(2026, 4, 14, 10, 0), actual.single().timeStart) + assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual.single().timeStop) + } + + private fun persistPoint(satelliteId: Long, time: LocalDateTime) { + entityManager.persist( + PDCMEntity( + satelliteId = satelliteId, + time = time, + revolution = 1, + x = 1.0, + y = 2.0, + z = 3.0, + vx = 4.0, + vy = 5.0, + vz = 6.0 + ) + ) + entityManager.flush() + } + + @TestConfiguration + class KafkaFilterStubConfiguration { + @Bean("kafkaListenerContainerFactory") + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + setConsumerFactory(testConsumerFactory()) + setAutoStartup(false) + } + + private fun testConsumerFactory(): ConsumerFactory = + DefaultKafkaConsumerFactory( + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9092", + ConsumerConfig.GROUP_ID_CONFIG to "test", + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java + ) + ) + + @Bean("placedIcFilter") + fun placedIcFilter(): RecordFilterStrategy = RecordFilterStrategy { false } + + @Bean("placedIcRvFilter") + fun placedIcRvFilter(): RecordFilterStrategy = RecordFilterStrategy { false } + + @Bean("updatedIcFilter") + fun updatedIcFilter(): RecordFilterStrategy = RecordFilterStrategy { false } + + @Bean("satelliteDeletedFilter") + fun satelliteDeletedFilter(): RecordFilterStrategy = RecordFilterStrategy { false } + } +} diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteServiceDeterminismTest.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteServiceDeterminismTest.kt new file mode 100644 index 0000000..af62877 --- /dev/null +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteServiceDeterminismTest.kt @@ -0,0 +1,56 @@ +package space.nstart.pcp.pcp_request_service.service + +import ballistics.types.Orientation +import ballistics.types.PointViewParams +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class SatelliteServiceDeterminismTest { + + @Test + fun `sortPointViews orders mpl points deterministically`() { + val service = SatelliteService() + + val sorted = service.sortPointViews( + listOf( + pointView(traverz = 120.0, vit = 3, pointNumb = 2, pv = 1, kren = 0.3), + pointView(traverz = 100.0, vit = 4, pointNumb = 1, pv = 0, kren = 0.5), + pointView(traverz = 100.0, vit = 3, pointNumb = 2, pv = 0, kren = 0.4), + pointView(traverz = 100.0, vit = 3, pointNumb = 1, pv = 1, kren = 0.2) + ) + ) + + assertEquals( + listOf( + 100.0 to 3, + 100.0 to 3, + 100.0 to 4, + 120.0 to 3 + ), + sorted.map { it.traverz to it.vit } + ) + assertEquals(listOf(1, 2, 1, 2), sorted.map { it.pointNumb }) + } + + private fun pointView( + traverz: Double, + vit: Int, + pointNumb: Int, + pv: Int, + kren: Double + ) = PointViewParams( + objON = 0, + objN = 0, + objUUID = "", + pointNumb = pointNumb, + vit = vit, + traverz = traverz, + latTraverz = 0.0, + longTraverz = 0.0, + orientation = Orientation(0.0, kren, 0.0), + range = 0.0, + sunAngle = 0.0, + sightAngle = 0.0, + pv = pv + ) +} diff --git a/services/pcp-ballistics-service/src/test/resources/application.yaml b/services/pcp-ballistics-service/src/test/resources/application.yaml new file mode 100644 index 0000000..d78417c --- /dev/null +++ b/services/pcp-ballistics-service/src/test/resources/application.yaml @@ -0,0 +1,69 @@ +spring: + application: + name: pcp-ballistics-service + cloud: + config: + enabled: false + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + kafka: + bootstrap-servers: 192.168.60.68:29092 + consumer: + group-id: pcp-ballistics-service + auto-offset-reset: earliest + template: + default-topic: pcp.tle + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://192.168.60.68:5432/pcp_ballistics + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + .: ERROR + file: + name: ./logs/application.log + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +settings: + calculation-interval: 28 + ic-service: http://192.168.60.68:9080 + +server: + port: 7003 diff --git a/services/pcp-complex-mission-service/Dockerfile b/services/pcp-complex-mission-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-complex-mission-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-complex-mission-service/build.gradle.kts b/services/pcp-complex-mission-service/build.gradle.kts new file mode 100644 index 0000000..4ccfa11 --- /dev/null +++ b/services/pcp-complex-mission-service/build.gradle.kts @@ -0,0 +1,111 @@ + +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +// id("org.graalvm.buildtools.native") +} + +version = "1.0.0" + + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation(project(":libs:ballistics-lib")) + + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") +// implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") +// implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.4") + + implementation("org.springframework.kafka:spring-kafka") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + implementation("org.locationtech.jts:jts-core:1.19.0") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + testRuntimeOnly("com.h2database:h2") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + enabled = true + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/PcpComplexMissionServiceApplication.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/PcpComplexMissionServiceApplication.kt new file mode 100644 index 0000000..50e820a --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/PcpComplexMissionServiceApplication.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_satellites_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync + +@EnableAsync +@SpringBootApplication +class PcpComplexMissionServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/ComplexPlanProperties.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/ComplexPlanProperties.kt new file mode 100644 index 0000000..67d3c37 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/ComplexPlanProperties.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_satellites_service.configuration + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "complex-plan") +class ComplexPlanProperties { + var batchChunkSize: Int = 100 +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/GlobalExceptionHandler.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..4fa4055 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_satellites_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(WebExchangeBindException::class) + fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { + val errors = mutableMapOf() + ex.bindingResult.fieldErrors.forEach { error -> + errors[error.field] = error.defaultMessage ?: "Validation error" + } + return Mono.just(ResponseEntity.badRequest().body(errors)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} \ No newline at end of file diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/KafkaConfig.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/KafkaConfig.kt new file mode 100644 index 0000000..0f34e0b --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/KafkaConfig.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.pcp_satellites_service.configuration + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean + fun consumerFactory(): ConsumerFactory { + val props = hashMapOf() + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers + props[ConsumerConfig.GROUP_ID_CONFIG] = groupId + props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "latest" + props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + + return DefaultKafkaConsumerFactory(props) + } + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory() + .apply { setConsumerFactory(consumerFactory()) } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/TimeConfiguration.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/TimeConfiguration.kt new file mode 100644 index 0000000..510d9fa --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/TimeConfiguration.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_satellites_service.configuration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock + +@Configuration +class TimeConfiguration { + + @Bean + fun clock(): Clock = Clock.systemUTC() +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/ValidationConfig.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/ValidationConfig.kt new file mode 100644 index 0000000..98b67e0 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/ValidationConfig.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_satellites_service.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator { + return Validation.buildDefaultValidatorFactory().validator + } +} \ No newline at end of file diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/WebClientConfig.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/WebClientConfig.kt new file mode 100644 index 0000000..3c51ac1 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/configuration/WebClientConfig.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.pcp_satellites_service.configuration + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.util.unit.DataSize +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class WebClientConfig( + @param:Value("\${spring.codec.max-in-memory-size:20MB}") + private val maxInMemorySize: DataSize +) { + + @Bean + fun webClientBuilder(): WebClient.Builder = + WebClient.builder() + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(maxInMemorySize.toBytes().toInt()) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanController.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanController.kt new file mode 100644 index 0000000..8b7c9d8 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanController.kt @@ -0,0 +1,132 @@ +package space.nstart.pcp.pcp_satellites_service.controller + +import jakarta.validation.Valid +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +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_satellites_service.dto.ComplexPlanRunFilterDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistenceService +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunQueryService +import space.nstart.pcp.pcp_satellites_service.service.SatelliteModeQueryService +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import tools.jackson.databind.json.JsonMapper +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@RestController +@RequestMapping("/api/com-plan") +class ComplexPlanController { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = JsonMapper.builder().findAndAddModules().build() + + @Autowired + private lateinit var complexMissionService: ComplexMissionService + + @Autowired + private lateinit var complexPlanRunOrchestrationService: ComplexPlanRunOrchestrationService + + @Autowired + private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService + + @Autowired + private lateinit var complexPlanRunQueryService: ComplexPlanRunQueryService + + @Autowired + private lateinit var satelliteModeQueryService: SatelliteModeQueryService + + @PostMapping("/process") + fun calc( + @Valid @RequestBody body: ComplexPlanProcessRequestDTO, + @RequestHeader(name = "X-Requested-By", required = false) requestedByHeader: String?, + @RequestHeader(name = "X-User", required = false) xUserHeader: String?, + @RequestHeader(name = "X-Forwarded-User", required = false) forwardedUserHeader: String? + ): ComplexPlanRunResponseDTO { + val requestedBy = requestedByHeader + ?: xUserHeader + ?: forwardedUserHeader + val request = body.copy(coverageStrategy = body.coverageStrategy ?: SlotCoverageStrategy.GREEDY) + logger.info( + "Complex plan process request started: interval=[{} - {}], satelliteId={}, satelliteIds={}, coverageSource={}, countLat={}, countLong={}, coverageStrategy={}, requestedBy={}", + request.intervalStart, + request.intervalEnd, + request.satelliteId, + request.satelliteIds, + request.coverageSource, + request.countLat, + request.countLong, + request.coverageStrategy, + requestedBy + ) + lateinit var response: ComplexPlanRunResponseDTO + val durationMs = measureTimeMillis { + response = complexPlanRunOrchestrationService.process( + request = request, + requestedBy = requestedBy + ) + } + logger.info( + "Complex plan process completed: runId={}, status={}, durationMs={}, responseBody={}", + response.runId, + response.status, + durationMs, + objectMapper.writeValueAsString(response) + ) + return response + } + + @PostMapping("/clear") + fun clear() = complexMissionService.clear() + + @GetMapping("/runs/{id}") + fun run(@PathVariable id: Long) = + complexPlanRunQueryService.getRun(id) + + @GetMapping("/runs/{id}/modes") + fun runModes(@PathVariable id: Long) = + satelliteModeQueryService.findModesByRun(id) + + @DeleteMapping("/runs/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteRun(@PathVariable id: Long) { + complexPlanRunPersistenceService.deleteRun(id) + } + + @GetMapping("/runs") + fun runs( + @RequestParam(required = false) status: ComplexPlanRunStatus?, + @RequestParam(required = false) createdFrom: LocalDateTime?, + @RequestParam(required = false) createdTo: LocalDateTime?, + @RequestParam(required = false) satelliteId: Long?, + @RequestParam(required = false) intervalStart: LocalDateTime?, + @RequestParam(required = false) intervalEnd: LocalDateTime? + ) = complexPlanRunQueryService.findRuns( + ComplexPlanRunFilterDTO( + status = status, + createdFrom = createdFrom, + createdTo = createdTo, + satelliteId = satelliteId, + intervalStart = intervalStart, + intervalEnd = intervalEnd + ) + ) + + @GetMapping("/cell-with-mars/{cell_number}") + fun paintCellWithMars(@PathVariable("cell_number") cellNumber: Long) = + satelliteModeQueryService.buildCellPaintGeometry(cellNumber) +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/controller/SatelliteController.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/controller/SatelliteController.kt new file mode 100644 index 0000000..c33068e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/controller/SatelliteController.kt @@ -0,0 +1,144 @@ +package space.nstart.pcp.pcp_satellites_service.controller + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanQueryService +import space.nstart.pcp.pcp_satellites_service.service.SatelliteService +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellCalculationRequestDTO +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/satellites") +class SatelliteController { + + @Autowired + private lateinit var complexMissionService: ComplexMissionService + + @Autowired + private lateinit var satelliteService: SatelliteService + + @Autowired + private lateinit var complexPlanQueryService: ComplexPlanQueryService + + @GetMapping + fun all() = satelliteService.all() + + @GetMapping("/{satellite_id}") + fun byId(@PathVariable("satellite_id") id: Long) = satelliteService.byId(id) + + @GetMapping("/{satellite_id}/mission") + fun planById(@PathVariable("satellite_id") id: Long) = + complexPlanQueryService.findMissionModes(requireSatelliteId(id)) + + @GetMapping("/{satellite_id}/mission/statistics") + fun missionStatistics(@PathVariable("satellite_id") id: Long) = + complexPlanQueryService.missionStatistics(requireSatelliteId(id)) + + @GetMapping("/{satellite_id}/mission-for-paint") + fun planByIdForPaint(@PathVariable("satellite_id") id: Long) = + complexPlanQueryService.buildPaintGeometry(requireSatelliteId(id)) + + @GetMapping("/{satellite_id}/modes") + fun modesBySatelliteAndInterval( + @PathVariable("satellite_id") id: Long, + @RequestParam("intervalStart") intervalStart: LocalDateTime, + @RequestParam("intervalEnd") intervalEnd: LocalDateTime, + @RequestParam("runId", required = false) runId: Long? + ) = complexPlanQueryService.findModesByInterval( + listOf(requireSatelliteId(id)), + intervalStart, + intervalEnd, + runId + ) + + @GetMapping("/{satellite_id}/snapshots") + fun snapshotsBySatellite( + @PathVariable("satellite_id") id: Long, + @RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?, + @RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime?, + @RequestParam("activeOnly", defaultValue = "false") activeOnly: Boolean + ) = complexPlanQueryService.findSnapshots( + satelliteId = requireSatelliteId(id), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + activeOnly = activeOnly + ) + + @GetMapping("/{satellite_id}/snapshots/active") + fun activeSnapshotBySatellite( + @PathVariable("satellite_id") id: Long, + @RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?, + @RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime? + ) = complexPlanQueryService.findActiveSnapshot( + satelliteId = requireSatelliteId(id), + intervalStart = intervalStart, + intervalEnd = intervalEnd + ) + + @GetMapping("/snapshots") + fun snapshots( + @RequestParam("satelliteId", required = false) satelliteId: Long?, + @RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?, + @RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime?, + @RequestParam("activeOnly", defaultValue = "false") activeOnly: Boolean + ) = complexPlanQueryService.findSnapshots( + satelliteId = satelliteId, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + activeOnly = activeOnly + ) + + @GetMapping("/snapshots/{snapshot_id}") + fun snapshot( + @PathVariable("snapshot_id") snapshotId: Long + ) = complexPlanQueryService.findSnapshot(snapshotId) + + @GetMapping("/snapshots/{snapshot_id}/modes") + fun modesBySnapshot( + @PathVariable("snapshot_id") snapshotId: Long, + @RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?, + @RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime? + ) = complexPlanQueryService.findModesBySnapshot(snapshotId, intervalStart, intervalEnd) + + @GetMapping("/modes") + fun modesByInterval( + @RequestParam("intervalStart") intervalStart: LocalDateTime, + @RequestParam("intervalEnd") intervalEnd: LocalDateTime, + @RequestParam("satelliteId", required = false) satelliteId: Long?, + @RequestParam("satelliteIds", required = false) satelliteIds: List? + ) = complexPlanQueryService.findModesByInterval( + buildSatelliteFilter(satelliteId, satelliteIds), + intervalStart, + intervalEnd + ) + + @PostMapping("prepare") + fun prepare() = complexMissionService.clear() + + private fun buildSatelliteFilter(satelliteId: Long?, satelliteIds: List?): List { + val ids = buildList { + satelliteId?.let { add(it) } + satelliteIds?.let { addAll(it) } + }.distinct() + + if (ids.isEmpty()) { + throw CustomValidationException("Необходимо задать satelliteId или satelliteIds") + } + + return ids + } + + private fun requireSatelliteId(id: Long): Long { + satelliteService.byId(id) ?: throw CustomErrorException("Нет КА с заданным идентификаторм $id") + return id + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunCreateDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunCreateDTO.kt new file mode 100644 index 0000000..ff4dce9 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunCreateDTO.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_satellites_service.dto + +import java.time.LocalDateTime + +data class ComplexPlanRunCreateDTO( + val intervalStart: LocalDateTime, + val intervalEnd: LocalDateTime, + val satelliteIds: List, + val requestedBy: String?, + val requestPayload: String? +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunFilterDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunFilterDTO.kt new file mode 100644 index 0000000..398a36c --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunFilterDTO.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_satellites_service.dto + +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import java.time.LocalDateTime + +data class ComplexPlanRunFilterDTO( + val status: ComplexPlanRunStatus? = null, + val createdFrom: LocalDateTime? = null, + val createdTo: LocalDateTime? = null, + val satelliteId: Long? = null, + val intervalStart: LocalDateTime? = null, + val intervalEnd: LocalDateTime? = null +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt new file mode 100644 index 0000000..56c5887 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_satellites_service.dto + +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import java.time.LocalDateTime +import java.time.OffsetDateTime + +data class ComplexPlanRunResponseDTO( + val runId: Long, + val status: ComplexPlanRunStatus, + val intervalStart: LocalDateTime, + val intervalEnd: LocalDateTime, + val satelliteIds: List, + val requestedBy: String?, + val createdAt: OffsetDateTime, + val startedAt: OffsetDateTime?, + val finishedAt: OffsetDateTime?, + val durationMs: Long?, + val errorMessage: String?, + val calculatedModesCount: Int, + val bookedModesCount: Int, + val complanModesCount: Int, + val mixedModesCount: Int, + val affectedSatellitesCount: Int, + val processedCellsCount: Int, + val bookedSlotLinksCount: Int, + val snapshotIds: List, + val requestPayload: String?, + val totalRequestsArea: Double = 0.0, + val coveredArea: Double = 0.0, + val coveredAreaPercent: Double = 0.0 +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunStatisticsDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunStatisticsDTO.kt new file mode 100644 index 0000000..210fb92 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunStatisticsDTO.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_satellites_service.dto + +data class ComplexPlanRunStatisticsDTO( + val calculatedModesCount: Int = 0, + val bookedModesCount: Int = 0, + val complanModesCount: Int = 0, + val mixedModesCount: Int = 0, + val affectedSatellitesCount: Int = 0, + val processedCellsCount: Int = 0, + val bookedSlotLinksCount: Int = 0, + val totalRequestsArea: Double = 0.0, + val coveredArea: Double = 0.0, + val coveredAreaPercent: Double = 0.0 +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt new file mode 100644 index 0000000..d401132 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt @@ -0,0 +1,30 @@ +package space.nstart.pcp.pcp_satellites_service.dto + +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import java.time.LocalDateTime +import java.time.OffsetDateTime + +data class ComplexPlanRunSummaryDTO( + val runId: Long, + val status: ComplexPlanRunStatus, + val intervalStart: LocalDateTime, + val intervalEnd: LocalDateTime, + val satelliteIds: List, + val requestedBy: String?, + val createdAt: OffsetDateTime, + val startedAt: OffsetDateTime?, + val finishedAt: OffsetDateTime?, + val durationMs: Long?, + val errorMessage: String?, + val calculatedModesCount: Int, + val bookedModesCount: Int, + val complanModesCount: Int, + val mixedModesCount: Int, + val affectedSatellitesCount: Int, + val processedCellsCount: Int, + val bookedSlotLinksCount: Int, + val snapshotIds: List, + val totalRequestsArea: Double = 0.0, + val coveredArea: Double = 0.0, + val coveredAreaPercent: Double = 0.0 +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt new file mode 100644 index 0000000..874fb10 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt @@ -0,0 +1,106 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "complex_plan_run", + indexes = [ + Index(name = "idx_complex_plan_run_status", columnList = "status"), + Index(name = "idx_complex_plan_run_created_at", columnList = "created_at"), + Index(name = "idx_complex_plan_run_interval_start", columnList = "interval_start"), + Index(name = "idx_complex_plan_run_interval_end", columnList = "interval_end") + ] +) +class ComplexPlanRunEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + var status: ComplexPlanRunStatus = ComplexPlanRunStatus.CREATED, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "started_at") + var startedAt: LocalDateTime? = null, + + @Column(name = "finished_at") + var finishedAt: LocalDateTime? = null, + + @Column(name = "duration_ms") + var durationMs: Long? = null, + + @Column(name = "interval_start", nullable = false) + var intervalStart: LocalDateTime = LocalDateTime.now(), + + @Column(name = "interval_end", nullable = false) + var intervalEnd: LocalDateTime = LocalDateTime.now(), + + @Column(name = "requested_by") + var requestedBy: String? = null, + + @Column(name = "error_message", columnDefinition = "TEXT") + var errorMessage: String? = null, + + @Column(name = "calculated_modes_count", nullable = false) + var calculatedModesCount: Int = 0, + + @Column(name = "booked_modes_count", nullable = false) + var bookedModesCount: Int = 0, + + @Column(name = "complan_modes_count", nullable = false) + var complanModesCount: Int = 0, + + @Column(name = "mixed_modes_count", nullable = false) + var mixedModesCount: Int = 0, + + @Column(name = "affected_satellites_count", nullable = false) + var affectedSatellitesCount: Int = 0, + + @Column(name = "processed_cells_count", nullable = false) + var processedCellsCount: Int = 0, + + @Column(name = "booked_slot_links_count", nullable = false) + var bookedSlotLinksCount: Int = 0, + + @Column(name = "total_requests_area", nullable = false) + var totalRequestsArea: Double = 0.0, + + @Column(name = "covered_area", nullable = false) + var coveredArea: Double = 0.0, + + @Column(name = "covered_area_percent", nullable = false) + var coveredAreaPercent: Double = 0.0, + + @Column(name = "request_payload", columnDefinition = "TEXT") + var requestPayload: String? = null, + + @OneToMany( + mappedBy = "run", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.LAZY + ) + var satellites: MutableList = mutableListOf(), + + @OneToMany( + mappedBy = "complexPlanRun", + fetch = FetchType.LAZY + ) + var snapshots: MutableList = mutableListOf() +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunSatelliteEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunSatelliteEntity.kt new file mode 100644 index 0000000..b1e86e4 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunSatelliteEntity.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "complex_plan_run_satellite", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_complex_plan_run_satellite_run_satellite", + columnNames = ["run_id", "satellite_id"] + ) + ], + indexes = [ + Index(name = "idx_complex_plan_run_satellite_run_id", columnList = "run_id"), + Index(name = "idx_complex_plan_run_satellite_satellite_id", columnList = "satellite_id") + ] +) +class ComplexPlanRunSatelliteEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "run_id", nullable = false) + var run: ComplexPlanRunEntity? = null, + + @Column(name = "satellite_id", nullable = false) + var satelliteId: Long = 0 +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunStatus.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunStatus.kt new file mode 100644 index 0000000..23a9e22 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunStatus.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +enum class ComplexPlanRunStatus { + CREATED, + RUNNING, + COMPLETED, + FAILED +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeBookedSlotEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeBookedSlotEntity.kt new file mode 100644 index 0000000..9fe508c --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeBookedSlotEntity.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import jakarta.persistence.Index + +@Entity +@Table( + name = "satellite_mode_booked_slot", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_satellite_mode_booked_slot_mode_booked_slot", + columnNames = ["mode_id", "booked_slot_id"] + ) + ], + indexes = [ + Index(name = "idx_satellite_mode_booked_slot_mode_id", columnList = "mode_id"), + Index(name = "idx_satellite_mode_booked_slot_booked_slot_id", columnList = "booked_slot_id") + ] +) +class SatelliteModeBookedSlotEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mode_id", nullable = false) + var mode: SatelliteModeEntity? = null, + + @Column(name = "booked_slot_id", nullable = false) + var bookedSlotId: Long = 0 +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeCellEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeCellEntity.kt new file mode 100644 index 0000000..aeb9fc1 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeCellEntity.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import jakarta.persistence.Index + +@Entity +@Table( + name = "satellite_mode_cell", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_satellite_mode_cell_mode_cell", + columnNames = ["mode_id", "cell_num"] + ) + ], + indexes = [ + Index(name = "idx_satellite_mode_cell_mode_id", columnList = "mode_id"), + Index(name = "idx_satellite_mode_cell_cell_num", columnList = "cell_num") + ] +) +class SatelliteModeCellEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mode_id", nullable = false) + var mode: SatelliteModeEntity? = null, + + @Column(name = "cell_num", nullable = false) + var cellNum: Long = 0 +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt new file mode 100644 index 0000000..1c519d5 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt @@ -0,0 +1,95 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.Index +import java.time.LocalDateTime + +@Entity +@Table( + name = "satellite_modes", + indexes = [ + Index(name = "idx_satellite_modes_satellite_id", columnList = "satellite_id"), + Index(name = "idx_satellite_modes_start_time", columnList = "start_time"), + Index(name = "idx_satellite_modes_end_time", columnList = "end_time"), + Index(name = "idx_satellite_modes_source", columnList = "source") + ] +) +class SatelliteModeEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @Column(name = "satellite_id", nullable = false) + var satelliteId: Long = 0, + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 20) + var source: SatelliteModeSource = SatelliteModeSource.COMPLAN, + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + var type: SatelliteModeType = SatelliteModeType.SURVEY, + + @Column(name = "start_time", nullable = false) + var startTime: LocalDateTime = LocalDateTime.now(), + + @Column(name = "end_time", nullable = false) + var endTime: LocalDateTime = LocalDateTime.now(), + + @Column(name = "revolution", nullable = false) + var revolution: Long = 0, + + @Column(name = "lat", nullable = false) + var lat: Double = 0.0, + + @Column(name = "longitude", nullable = false) + var longitude: Double = 0.0, + + @Column(name = "duration", nullable = false) + var duration: Double = 0.0, + + @Column(name = "contour_wkt", columnDefinition = "TEXT") + var contourWkt: String? = null, + + @Column(name = "roll", nullable = false) + var roll: Double = 0.0, + + @Column(name = "cell_num", nullable = false) + var cellNum: Long = -1, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "snapshot_id", nullable = false) + var snapshot: SatelliteModeSnapshotEntity? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "complex_plan_run_id") + var complexPlanRun: ComplexPlanRunEntity? = null, + + @OneToMany( + mappedBy = "mode", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.LAZY + ) + var bookedSlots: MutableList = mutableListOf(), + + @OneToMany( + mappedBy = "mode", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.LAZY + ) + var cells: MutableList = mutableListOf() +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt new file mode 100644 index 0000000..4d44d0e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt @@ -0,0 +1,62 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "satellite_mode_snapshots", + indexes = [ + Index(name = "idx_satellite_mode_snapshots_satellite_id", columnList = "satellite_id"), + Index(name = "idx_satellite_mode_snapshots_interval_start", columnList = "interval_start"), + Index(name = "idx_satellite_mode_snapshots_interval_end", columnList = "interval_end"), + Index(name = "idx_satellite_mode_snapshots_is_active", columnList = "is_active"), + Index(name = "idx_satellite_mode_snapshots_run_id", columnList = "complex_plan_run_id") + ] +) +class SatelliteModeSnapshotEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @Column(name = "satellite_id", nullable = false) + var satelliteId: Long = 0, + + @Column(name = "interval_start", nullable = false) + var intervalStart: LocalDateTime = LocalDateTime.now(), + + @Column(name = "interval_end", nullable = false) + var intervalEnd: LocalDateTime = LocalDateTime.now(), + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = false, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "complex_plan_run_id") + var complexPlanRun: ComplexPlanRunEntity? = null, + + @Column(name = "comment", columnDefinition = "TEXT") + var comment: String? = null, + + @OneToMany( + mappedBy = "snapshot", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.LAZY + ) + var modes: MutableList = mutableListOf() +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSource.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSource.kt new file mode 100644 index 0000000..c5a690f --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSource.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +enum class SatelliteModeSource { + SLOTS, + COMPLAN, + MIXED, + MANUAL +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeType.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeType.kt new file mode 100644 index 0000000..f35bf8c --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeType.kt @@ -0,0 +1,5 @@ +package space.nstart.pcp.pcp_satellites_service.entity + +enum class SatelliteModeType { + SURVEY +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/BLS.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/BLS.kt new file mode 100644 index 0000000..afebabf --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/BLS.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_satellites_service.model + +class BLS( + val captureAngle : Double = 1.5, + val sunAngleMin : Double = -90.0, + val durationMin : Double = 10.0, + val durationMax : Double = 300.0, + val mmi : Double = 10.0, + val dailyMaxDuration : Double = 35.0 * 60, + val revolutionMaxDuration : Double = 5.0 * 60 +){ +} \ No newline at end of file diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/LongTermMission.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/LongTermMission.kt new file mode 100644 index 0000000..b63e670 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/LongTermMission.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp.pcp_satellites_service.model + +import org.locationtech.jts.index.bintree.Interval +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import java.time.LocalDateTime + +class LongTermMission( + val surveys : MutableList = mutableListOf() +) { + + fun hasResources(tStart : LocalDateTime, tStop : LocalDateTime, bls: BLS) : Boolean{ + + + + return true + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..9ea9535 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/SatelliteModel.kt @@ -0,0 +1,149 @@ +package space.nstart.pcp.pcp_satellites_service.model + +import ballistics.flightLine.PointOnEarthCalculator +import ballistics.orbitalPoints.timeStepper.RungeStepper +import ballistics.types.EarthType +import ballistics.types.ModDVType +import ballistics.types.OrbitalPoint +import ballistics.types.Orientation +import ballistics.types.THBLPoint +import ballistics.types.WorkCSType +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.io.WKTWriter +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import java.time.LocalDate +import java.time.LocalDateTime +import kotlin.div +import kotlin.math.ceil +import kotlin.math.PI +import kotlin.times + + +@Component +class SatelliteModel( + val satelliteId : Long = 0, + val name : String = "", + val red : Short = 255, + val green : Short = 0, + val blue : Short = 0, + val longMission : LongTermMission = LongTermMission(), + val bls: BLS = BLS(), + val scanTLE : Boolean = false + +) { + + fun toDTO() = SatelliteInfoDTO( + satelliteId, + name, + red, + green, + blue, + bls.captureAngle, + scanTLE, + bls.durationMax.toLong(), + ceil(bls.mmi).toLong() + ) + + + fun contourByTime( + tn : Double, + roll: Double, + capture : Double, + points : List + ) : String { + + + + return "" + } + + + fun contour( + tn : Double, + tk : Double, + roll: Double, + capture : Double, + points : List) : String{ + //val vp = points.filter { p -> p.t>=tn && p.t <= tk } + val stepper: RungeStepper = RungeStepper(points.toMutableList(), EarthType.PZ90d02) + var t = tn + val vp = mutableListOf() + while(t <= tk) { + stepper.calculate(t)?.let { vp.add(it) } + + t += 1.0 + } + val r = vp.map { + viewParams(it, roll + capture, 0.0) ?: THBLPoint( + 0.0, + 0.0, + 0.0, + 0.0 + ) + } + val l = vp.map { + viewParams(it, roll - capture, 0.0) ?: THBLPoint( + 0.0, + 0.0, + 0.0, + 0.0 + ) + } + val geom = mutableListOf() + for (rp in r) + geom.add(Coordinate(rp.long * 180 / PI, rp.lat * 180 / PI, 0.0)) + for (lp in l.reversed()) + geom.add(Coordinate(lp.long * 180 / PI, lp.lat * 180 / PI, 0.0)) + if (!geom.isEmpty()) + geom.add(Coordinate(geom.first().x, geom.first().y)) + val geometryFactory = GeometryFactory() + val shell = geometryFactory.createLinearRing(geom.toTypedArray()) + val polygon = geometryFactory.createPolygon(shell) + val wktWriter = WKTWriter() + return wktWriter.write(polygon) + } + + fun viewParams(pos : OrbitalPoint, gamma : Double, tang : Double) : THBLPoint?{ + val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + return calculator.pointOnEarth(pos, Orientation(tang * PI / 180.0, gamma * PI / 180.0, 0.0)) + } + + fun hasResources(dat : LocalDate) : Boolean{ + if (longMission.surveys + .filter { it.time.toLocalDate() == dat } + .sumOf { view -> view.duration } >= bls.dailyMaxDuration - bls.durationMin + ) + return false + return true + } + + fun canBeUsed(view : SquareViewParamDTO) : Boolean{ + + if (longMission.surveys + .filter { it.time.toLocalDate() == view.timeBegin.toLocalDate() } + .sumOf { view -> view.duration } > bls.dailyMaxDuration + ) + return false + if (longMission.surveys + .filter { it.revolution == view.revolutionBegin } + .sumOf { view -> view.duration } > bls.revolutionMaxDuration + ) + return false +// val intersections = longMission.surveys +// .filter { surv -> +// val tn1 = surv.time.plusSeconds(surv.duration.toLong() + bls.mmi.toLong()) +// val tk1 = surv.time.minusSeconds(bls.mmi.toLong()) +// val tn2 = view.timeBegin +// val tk2 = view.timeEnd +// +// val intersects = (tk1 <= tn2 && tk2 <= tn1) +// intersects +// } +// return intersections.isEmpty() + return true + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt new file mode 100644 index 0000000..05fc0bf --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp.pcp_satellites_service.model.mode + +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO +import java.time.LocalDateTime + +class SurveyMode( + val revolution : Long = 1, + val time : LocalDateTime = LocalDateTime.now(), + val timeStop : LocalDateTime = LocalDateTime.now(), + val roll : Double = 0.0, + val latitude : Double = 0.0, + val longitude : Double = 0.0, + val duration : Double = 0.0, + val contourWKT : String = "", + val cellNum : Long = 0, + val source: SatelliteModeSource = SatelliteModeSource.COMPLAN, + val bookedSlotIds: List = emptyList(), + val cellNums: List = emptyList() +) { + fun toDTO() = SurveyModeInfoDTO( + revolution = revolution, + time = time, + timStop = timeStop, + roll = roll, + duration = duration, + contourWKT = contourWKT, + slotIds = bookedSlotIds, + latitude = latitude, + longitude = longitude + ) +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/ComplexPlanRunRepository.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/ComplexPlanRunRepository.kt new file mode 100644 index 0000000..d9673ef --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/ComplexPlanRunRepository.kt @@ -0,0 +1,41 @@ +package space.nstart.pcp.pcp_satellites_service.repository + +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import java.time.LocalDateTime + +interface ComplexPlanRunRepository : JpaRepository, JpaSpecificationExecutor { + + @EntityGraph(attributePaths = ["satellites"]) + @Query("select run from ComplexPlanRunEntity run where run.id = :id") + fun findDetailedById(@Param("id") id: Long): ComplexPlanRunEntity? + + @EntityGraph(attributePaths = ["satellites"]) + fun findAllByStatusIn(statuses: Collection): List + + @Query( + """ + select distinct run.id + from ComplexPlanRunEntity run + join run.satellites satellite + where run.status = :status + and run.id <> :excludedRunId + and satellite.satelliteId in :satelliteIds + and run.intervalStart < :intervalEnd + and :intervalStart < run.intervalEnd + order by run.id + """ + ) + fun findOverlappingRunIdsByStatusAndSatelliteIds( + @Param("status") status: ComplexPlanRunStatus, + @Param("satelliteIds") satelliteIds: Collection, + @Param("intervalStart") intervalStart: LocalDateTime, + @Param("intervalEnd") intervalEnd: LocalDateTime, + @Param("excludedRunId") excludedRunId: Long + ): List +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/ComplexPlanRunSatelliteRepository.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/ComplexPlanRunSatelliteRepository.kt new file mode 100644 index 0000000..197850e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/ComplexPlanRunSatelliteRepository.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_satellites_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunSatelliteEntity + +interface ComplexPlanRunSatelliteRepository : JpaRepository { + + fun countBySatelliteId(satelliteId: Long): Long + + fun deleteAllBySatelliteId(satelliteId: Long): Long +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeBookedSlotRepository.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeBookedSlotRepository.kt new file mode 100644 index 0000000..9aefb99 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeBookedSlotRepository.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_satellites_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 space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity + +interface SatelliteModeBookedSlotRepository : JpaRepository { + fun findAllByMode_IdIn(modeIds: Collection): List + + @Modifying + @Query("delete from SatelliteModeBookedSlotEntity entity where entity.mode.id in :modeIds") + fun deleteAllByModeIds(@Param("modeIds") modeIds: Collection): Int +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeCellRepository.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeCellRepository.kt new file mode 100644 index 0000000..6f9d246 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeCellRepository.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_satellites_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 space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeCellEntity + +interface SatelliteModeCellRepository : JpaRepository { + fun findAllByMode_IdIn(modeIds: Collection): List + + @Modifying + @Query("delete from SatelliteModeCellEntity entity where entity.mode.id in :modeIds") + fun deleteAllByModeIds(@Param("modeIds") modeIds: Collection): Int +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeRepository.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeRepository.kt new file mode 100644 index 0000000..43991ce --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeRepository.kt @@ -0,0 +1,88 @@ +package space.nstart.pcp.pcp_satellites_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity +import java.time.LocalDateTime + +interface SatelliteModeRepository : JpaRepository { + + fun findAllBySnapshot_IdInOrderByStartTimeAsc( + snapshotIds: Collection + ): List + + fun findAllBySnapshot_IdOrderByStartTimeAsc(snapshotId: Long): List + + fun findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): List + + fun findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc( + satelliteIds: Collection, + revolutions: Collection + ): List + + fun findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc( + satelliteId: Long, + revolutions: Collection + ): List + + fun findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId: Long): List + + fun findAllByCellNumOrderByStartTimeAsc(cellNum: Long): List + + fun findAllByComplexPlanRun_IdOrderByStartTimeAsc(runId: Long): List + + fun countByComplexPlanRun_Id(runId: Long): Long + + fun countBySatelliteId(satelliteId: Long): Long + + @Query( + """ + select entity.id + from SatelliteModeEntity entity + where entity.satelliteId in :satelliteIds + and entity.startTime < :intervalEnd + and entity.endTime > :intervalStart + """ + ) + fun findIntersectingIdsBySatelliteIdsAndInterval( + @Param("satelliteIds") satelliteIds: Collection, + @Param("intervalStart") intervalStart: LocalDateTime, + @Param("intervalEnd") intervalEnd: LocalDateTime + ): List + + @Query( + """ + select distinct entity + from SatelliteModeEntity entity + join entity.cells cell + where cell.cellNum = :cellNum + and entity.snapshot.isActive = true + order by entity.startTime asc + """ + ) + fun findAllByRelatedCellNumOrderByStartTimeAsc(@Param("cellNum") cellNum: Long): List + + @Modifying + @Transactional + @Query( + """ + DELETE FROM satellite_modes + WHERE satellite_id IN (:satelliteIds) + AND start_time < :intervalEnd + AND end_time > :intervalStart + """, + nativeQuery = true + ) + fun deleteIntersectingBySatelliteIdsAndInterval( + @Param("satelliteIds") satelliteIds: Collection, + @Param("intervalStart") intervalStart: LocalDateTime, + @Param("intervalEnd") intervalEnd: LocalDateTime + ): Int +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeSnapshotRepository.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeSnapshotRepository.kt new file mode 100644 index 0000000..a39f767 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/repository/SatelliteModeSnapshotRepository.kt @@ -0,0 +1,59 @@ +package space.nstart.pcp.pcp_satellites_service.repository + +import jakarta.transaction.Transactional +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity +import java.time.LocalDateTime + +interface SatelliteModeSnapshotRepository : + JpaRepository, + JpaSpecificationExecutor { + + fun findAllByComplexPlanRun_IdOrderByIdAsc(runId: Long): List + + fun findAllByComplexPlanRun_IdAndSatelliteIdInOrderBySatelliteIdAscCreatedAtDesc( + runId: Long, + satelliteIds: Collection + ): List + + fun findAllBySatelliteIdInAndIsActiveTrueOrderBySatelliteIdAscCreatedAtDesc( + satelliteIds: Collection + ): List + + fun findFirstBySatelliteIdAndIsActiveTrueOrderByCreatedAtDescIdDesc(satelliteId: Long): SatelliteModeSnapshotEntity? + + fun countBySatelliteId(satelliteId: Long): Long + + fun deleteAllBySatelliteId(satelliteId: Long): Long + + @Modifying + @Transactional + @Query( + """ + update SatelliteModeSnapshotEntity snapshot + set snapshot.isActive = false + where snapshot.satelliteId = :satelliteId + and snapshot.isActive = true + """ + ) + fun deactivateActiveSnapshots(@Param("satelliteId") satelliteId: Long): Int + + @Query( + """ + select snapshot.id + from SatelliteModeSnapshotEntity snapshot + where snapshot.satelliteId in :satelliteIds + and snapshot.intervalStart < :intervalEnd + and snapshot.intervalEnd > :intervalStart + """ + ) + fun findIntersectingIdsBySatelliteIdsAndInterval( + @Param("satelliteIds") satelliteIds: Collection, + @Param("intervalStart") intervalStart: LocalDateTime, + @Param("intervalEnd") intervalEnd: LocalDateTime + ): List +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/BallisticsService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/BallisticsService.kt new file mode 100644 index 0000000..a129bb0 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/BallisticsService.kt @@ -0,0 +1,57 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamsWithPointsDTO + + +@Service +class BallisticsService(webClientBuilderProvider: ObjectProvider){ + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.ballistics-service:ic-service}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + + fun mplSquare(req : ObjViewRequestDTO) = + client() + .post() + .uri("/api/obj-view/mpl-square") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux(SquareViewParamDTO::class.java) + .toIterable() + + + + fun cellCovering(cell : Long) = + client() + .get() + .uri("/api/obj-view/cell-coverage/{cell}", cell) + .retrieve() + .bodyToFlux(SquareViewParamsWithPointsDTO::class.java) + .toIterable() + + + fun exactTime(sat : Long, req : ExactTimePositionRequestDTO) = + client() + .post() + .uri("/api/satellites/{satelliteId}/extract-time", sat) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux(OrbPointDTO::class.java) + .toIterable() + + +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexMissionService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexMissionService.kt new file mode 100644 index 0000000..7cb1d2c --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexMissionService.kt @@ -0,0 +1,41 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import ballistics.types.OrbitalPoint +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageResponseStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellCalculationRequestDTO +import java.time.LocalDateTime +import kotlin.math.abs + + +@Service +class ComplexMissionService { + + @Autowired + private lateinit var satelliteService: SatelliteService + @Autowired + private lateinit var ballisticsService: BallisticsService + private val logger : Logger = LoggerFactory.getLogger(this::class.java) + + @Autowired + private lateinit var complexPlanCalculationService: ComplexPlanCalculationService + + fun clear() = + complexPlanCalculationService.clear() + + fun process(tn : LocalDateTime, tk : LocalDateTime, satsAvail : List?){ + complexPlanCalculationService.process(tn, tk, satsAvail) + } +} 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 new file mode 100644 index 0000000..4abe92b --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt @@ -0,0 +1,616 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.io.WKTWriter +import org.locationtech.jts.operation.union.UnaryUnionOp +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.configuration.ComplexPlanProperties +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.LongTermMission +import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +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 java.time.Duration +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@Service +class ComplexPlanCalculationService( + private val coverageBatchClient: CoverageBatchClient, + private val coverageCalculationClientResolver: CoverageCalculationClientResolver, + private val earthGridService: EarthGridService, + private val satelliteService: SatelliteService, + private val complexPlanQueryService: ComplexPlanQueryService, + private val complexPlanPersistenceService: ComplexPlanPersistenceService, + private val complexPlanProperties: ComplexPlanProperties, + private val surveyPlacementService: SurveyPlacementService +) { + + private val logger: Logger = LoggerFactory.getLogger(this::class.java) + + fun process( + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + satelliteIds: List?, + sun: Double? = null, + coverageSource: CoverageCalculationSource = CoverageCalculationSource.SLOTS, + countLat: Int? = null, + countLong: Int? = null, + coverageStrategy: SlotCoverageStrategy? = null + ): ComplexPlanCalculationResult = + process( + runId = null, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = satelliteIds, + sun = sun, + coverageSource = coverageSource, + countLat = countLat, + countLong = countLong, + coverageStrategy = coverageStrategy + ) + + fun process( + runId: Long?, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + satelliteIds: List?, + sun: Double? = null, + coverageSource: CoverageCalculationSource = CoverageCalculationSource.SLOTS, + countLat: Int? = null, + countLong: Int? = null, + coverageStrategy: SlotCoverageStrategy? = null + ): ComplexPlanCalculationResult { + val calculationStartedAtMs = System.currentTimeMillis() + val selectedSatelliteIds = (satelliteIds ?: satelliteService.satellites.map { it.satelliteId }).distinct() + if (selectedSatelliteIds.isEmpty()) { + logger.info("Расчет комплексного плана пропущен: runId={}, не задано ни одного спутника", runId) + return ComplexPlanCalculationResult() + } + lateinit var workingSatellites: List + val buildSatellitesMs = measureTimeMillis { + workingSatellites = buildWorkingSatellites(selectedSatelliteIds) + } + logger.info( + "Stage satellites-build: runId={}, requestedSatellites={}, workingSatellites={}, durationMs={}", + runId, + selectedSatelliteIds.size, + workingSatellites.size, + buildSatellitesMs + ) + val coverageCalculationClient = coverageCalculationClientResolver.resolve(coverageSource) + + logger.info( + "Старт расчета комплексного плана: runId={}, satellites={}, interval=[{} - {}], chunkSize={}, sun={}, coverageSource={}, countLat={}, countLong={}, coverageStrategy={}", + runId, + selectedSatelliteIds.size, + intervalStart, + intervalEnd, + complexPlanProperties.batchChunkSize, + sun, + coverageSource, + countLat, + countLong, + coverageStrategy + ) + logger.info("Шаг snapshot: runId={}, загрузка рабочего состояния вне пересчитываемого интервала", runId) + val snapshotMs = measureTimeMillis { + initializeWorkingModes(workingSatellites, intervalStart, intervalEnd) + } + logger.info( + "Stage snapshot-load: runId={}, satellites={}, durationMs={}", + runId, + workingSatellites.size, + snapshotMs + ) + + try { + var cells = emptyList() + val cellsLoadMs = measureTimeMillis { + cells = earthGridService.cells(countLat, countLong) + .toList() + .sortedWith( + compareByDescending { it.importance } + .thenByDescending { it.requests.count() } + .thenBy { it.num } + ) + } + logger.info("Stage cells-load: runId={}, cells={}, durationMs={}", runId, cells.size, cellsLoadMs) + val requestGeometry = buildRequestsGeometry(cells) + val totalRequestsArea = requestGeometry?.area ?: 0.0 + logger.info( + "Stage requests-area: runId={}, totalRequestsArea={}", + runId, + totalRequestsArea + ) + + logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId) + val plannedModesMs = measureTimeMillis { + addPlannedModes(workingSatellites, intervalStart, intervalEnd) + } + logger.info( + "Stage planned-modes-load: runId={}, satellites={}, durationMs={}", + runId, + workingSatellites.size, + plannedModesMs + ) + + val chunkSize = complexPlanProperties.batchChunkSize.coerceAtLeast(1) + val chunkCount = (cells.size + chunkSize - 1) / chunkSize + logger.info("Stage chunk-prepare: runId={}, chunkSize={}, chunks={}", runId, chunkSize, chunkCount) + + var processedCells = 0 + var acceptedSlots = 0 + for ((chunkIndex, chunk) in cells.chunked(chunkSize).withIndex()) { + if (!hasRemainingCapacity(workingSatellites, intervalStart, intervalEnd)) { + logger.info( + "Ранняя остановка перед chunk {}: runId={}, ресурс ОГ исчерпан, processedCells={}", + chunkIndex + 1, + runId, + processedCells + ) + break + } + + val targetsChunk = buildTargetsChunk(chunk) + var coverageByTargetId = emptyMap>() + val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites) + val coverageFetchMs = measureTimeMillis { + coverageByTargetId = coverageCalculationClient.coverageModes( + targets = targetsChunk, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = selectedSatelliteIds, + occupiedIntervals = occupiedIntervals, + sun = sun, + coverageStrategy = coverageStrategy + ) + } + logger.info( + "Stage chunk-coverage-fetch: runId={}, chunk={}/{}, cells={}, targets={}, occupiedIntervals={}, returnedTargets={}, durationMs={}", + runId, + chunkIndex + 1, + chunkCount, + chunk.size, + targetsChunk.size, + occupiedIntervals.size, + coverageByTargetId.size, + coverageFetchMs + ) + + var acceptedForChunk = 0 + val chunkProcessMs = measureTimeMillis { + acceptedForChunk = processChunk( + workingSatellites = workingSatellites, + cells = chunk, + coverageByTargetId = coverageByTargetId, + intervalStart = intervalStart, + intervalEnd = intervalEnd + ) + } + processedCells += chunk.size + acceptedSlots += acceptedForChunk + logger.info( + "Stage chunk-process: runId={}, chunk={}/{}, cells={}, acceptedModes={}, durationMs={}", + runId, + chunkIndex + 1, + chunkCount, + chunk.size, + acceptedForChunk, + chunkProcessMs + ) + coverageByTargetId = emptyMap() + + if (shouldStop(workingSatellites, intervalStart, intervalEnd)) { + logger.info( + "Ранняя остановка после chunk {}: runId={}, ресурс ОГ исчерпан, processedCells={}, acceptedModes={}", + chunkIndex + 1, + runId, + processedCells, + acceptedSlots + ) + break + } + } + + var saveResult = SatelliteModeSaveResult() + val persistMs = measureTimeMillis { + saveResult = saveCalculatedModes( + runId = runId, + workingSatellites = workingSatellites, + intervalStart = intervalStart, + intervalEnd = intervalEnd + ) + } + logger.info( + "Stage persist-modes: runId={}, processedCells={}, acceptedModes={}, persistedModes={}, durationMs={}", + runId, + processedCells, + acceptedSlots, + saveResult.calculatedModesCount, + persistMs + ) + var coveredArea = 0.0 + val coverageAreaMs = measureTimeMillis { + coveredArea = calculateCoveredArea(workingSatellites, intervalStart, intervalEnd) + } + val coveredAreaPercent = coveredAreaPercent(totalRequestsArea, coveredArea) + logger.info( + "Stage coverage-area: runId={}, totalRequestsArea={}, coveredArea={}, coveredAreaPercent={}, durationMs={}", + runId, + totalRequestsArea, + coveredArea, + coveredAreaPercent, + coverageAreaMs + ) + logger.info( + "Расчет комплексного плана завершен успешно: runId={}, calculatedModes={}, bookedModes={}, complanModes={}, mixedModes={}, affectedSatellites={}, processedCells={}, bookedSlotLinks={}, totalRequestsArea={}, coveredArea={}, coveredAreaPercent={}, durationMs={}", + runId, + saveResult.calculatedModesCount, + saveResult.bookedModesCount, + saveResult.complanModesCount, + saveResult.mixedModesCount, + saveResult.affectedSatellitesCount, + saveResult.processedCellsCount, + saveResult.bookedSlotLinksCount, + totalRequestsArea, + coveredArea, + coveredAreaPercent, + System.currentTimeMillis() - calculationStartedAtMs + ) + return ComplexPlanCalculationResult( + calculatedModesCount = saveResult.calculatedModesCount, + bookedModesCount = saveResult.bookedModesCount, + complanModesCount = saveResult.complanModesCount, + mixedModesCount = saveResult.mixedModesCount, + affectedSatellitesCount = saveResult.affectedSatellitesCount, + processedCellsCount = saveResult.processedCellsCount, + bookedSlotLinksCount = saveResult.bookedSlotLinksCount, + totalRequestsArea = totalRequestsArea, + coveredArea = coveredArea, + coveredAreaPercent = coveredAreaPercent + ) + } finally { + logger.info( + "Очистка локального рабочего состояния расчета: runId={}, elapsedMs={}", + runId, + System.currentTimeMillis() - calculationStartedAtMs + ) + } + } + + fun clear() { + logger.info("Полная очистка persisted и временного состояния complex plan") + complexPlanPersistenceService.clearAll() + satelliteService.clear() + } + + private fun addPlannedModes( + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ) { + val satelliteIds = workingSatellites.map { it.satelliteId } + val plannedModesBySatelliteId = coverageBatchClient.plannedModes(satelliteIds, intervalStart, intervalEnd) + val totalPlannedModes = plannedModesBySatelliteId.values.sumOf { it.size } + logger.info( + "Получено {} booked-маршрутов из slots-service для {} спутников", + totalPlannedModes, + plannedModesBySatelliteId.size + ) + + 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 + ) + } + } + + private fun buildCoverageTargets(cells: List): List = + cells.map { cell -> + SlotCoverageTargetDTO( + targetId = cell.targetId(), + wkt = cellTargetContour(cell) + ) + } + + private fun buildTargetsChunk(cells: List): List = + buildCoverageTargets(cells) + + private fun buildOccupiedIntervalsSnapshot(workingSatellites: List): List = + workingSatellites + .asSequence() + .flatMap { satellite -> + satellite.longMission.surveys.asSequence().map { survey -> + OccupiedIntervalDTO( + satelliteId = satellite.satelliteId, + startTime = survey.time, + endTime = survey.timeStop, + roll = survey.roll, + source = survey.source.name + ) + } + } + .sortedWith(compareBy { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime }) + .toList() + + private fun processChunk( + workingSatellites: List, + cells: List, + coverageByTargetId: Map>, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Int { + var acceptedModes = 0 + + for (cell in cells) { + val resourceAvailableSatelliteIds = collectResourceAvailableSatelliteIds(workingSatellites, intervalStart, intervalEnd) + if (resourceAvailableSatelliteIds.isEmpty()) { + break + } + + val activeSatelliteIds = resourceAvailableSatelliteIds.toSet() + coverageByTargetId[cell.targetId()] + .orEmpty() + .asSequence() + .filter { slot -> activeSatelliteIds.contains(slot.satelliteId) } + .forEach { slot -> + val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId } + ?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван") + + val decision = surveyPlacementService.tryPlaceSurvey( + 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 = cell.num, + source = SatelliteModeSource.COMPLAN, + cellNums = listOf(cell.num) + ), + satellite = satellite + ) + if (decision.accepted) { + acceptedModes++ + } else { + logger.debug( + "Candidate rejected by placement: satelliteId={}, cellNum={}, revolution={}, reason={}", + slot.satelliteId, + cell.num, + slot.revolution, + decision.rejectionReason + ) + } + } + } + + return acceptedModes + } + + private fun hasRemainingCapacity( + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Boolean = + collectResourceAvailableSatelliteIds(workingSatellites, intervalStart, intervalEnd).isNotEmpty() + + private fun shouldStop( + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Boolean = !hasRemainingCapacity(workingSatellites, intervalStart, intervalEnd) + + private fun initializeWorkingModes( + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ) { + val workingModes = complexPlanQueryService.loadConstraintModes( + workingSatellites.map { it.satelliteId }, + intervalStart, + intervalEnd + ) + workingSatellites.forEach { satellite -> + satellite.longMission.surveys.clear() + satellite.longMission.surveys.addAll(workingModes[satellite.satelliteId] + .orEmpty() + .filter { mode -> mode.timeStop <= intervalStart || mode.time >= intervalEnd || mode.source == SatelliteModeSource.MANUAL}) + logger.info( + "Загружено рабочее состояние для satelliteId={}: modesOutsideInterval={}", + satellite.satelliteId, + satellite.longMission.surveys.size + ) + } + } + + private fun saveCalculatedModes( + runId: Long?, + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): SatelliteModeSaveResult { + val satelliteIds = workingSatellites.map { it.satelliteId } + val surveysBySatelliteId = workingSatellites + .associate { satellite -> satellite.satelliteId to satellite.longMission.surveys.toList() } + val totalModes = surveysBySatelliteId.values.sumOf { it.size } + logger.info( + "Подготовлены данные к сохранению: runId={}, satellites={}, totalModes={}", + runId, + surveysBySatelliteId.keys, + totalModes + ) + + return complexPlanPersistenceService.replaceModesForInterval( + satelliteIds = satelliteIds, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = surveysBySatelliteId, + complexPlanRunId = runId + ) + } + + private fun buildWorkingSatellites(satelliteIds: List): List = + satelliteService.satellites + .filter { satelliteIds.contains(it.satelliteId) } + .map { satellite -> + SatelliteModel( + satelliteId = satellite.satelliteId, + name = satellite.name, + red = satellite.red, + green = satellite.green, + blue = satellite.blue, + longMission = LongTermMission(), + bls = satellite.bls, + scanTLE = satellite.scanTLE + ) + } + + private fun collectResourceAvailableSatelliteIds( + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): List { + val availableSatelliteIds = linkedSetOf() + var day = intervalStart.toLocalDate() + val lastDay = intervalEnd.toLocalDate().minusDays(1) + + while (day <= lastDay) { + workingSatellites.forEach { satellite -> + if (satellite.hasResources(day)) { + availableSatelliteIds += satellite.satelliteId + } + } + day = day.plusDays(1) + } + + return availableSatelliteIds.toList() + } + + private fun cellTargetContour(cell: EarthCellWithRequestsDTO): String { + val requestContours = cell.requests + .map { it.contour.trim() } + .filter { it.isNotEmpty() } + + if (requestContours.isEmpty()) { + return cell.contour + } + + return requestContours.drop(1).fold(requestContours.first()) { merged, contour -> + unionContours(merged, contour) + } + } + + private fun unionContours(baseContour: String, contourToAdd: String): String { + return try { + val reader = WKTReader() + val writer = WKTWriter() + writer.write(reader.read(baseContour).union(reader.read(contourToAdd))) + } catch (e: Exception) { + logger.warn("Не удалось объединить контуры маршрутов: {}", e.message) + baseContour + } + } + + private fun buildRequestsGeometry(cells: List): Geometry? { + val requestContours = cells + .asSequence() + .flatMap { cell -> cell.requests.asSequence() } + .distinctBy { request -> requestFragmentKey(request.id, request.requestId, request.contour) } + .map { request -> request.contour } + .filter { contour -> contour.isNotBlank() } + .toList() + + return unionGeometries(requestContours, "request") + } + + private fun requestFragmentKey(id: Long, requestId: String, contour: String): String = + if (id > 0) { + "fragment:$id" + } else { + "request:${requestId.takeIf { it.isNotBlank() } ?: "unknown"}:$contour" + } + + private fun calculateCoveredArea( + workingSatellites: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Double { + val coveredContours = workingSatellites + .asSequence() + .flatMap { satellite -> satellite.longMission.surveys.asSequence() } + .filter { survey -> survey.source != SatelliteModeSource.MANUAL } + .filter { survey -> survey.time < intervalEnd && intervalStart < survey.timeStop } + .map { survey -> survey.contourWKT } + .filter { contour -> contour.isNotBlank() } + .toList() + + return unionGeometries(coveredContours, "covered")?.area ?: 0.0 + } + + private fun unionGeometries(contours: List, context: String): Geometry? { + val reader = WKTReader() + val geometries = contours.mapNotNull { contour -> + runCatching { reader.read(contour) } + .onFailure { e -> logger.warn("Не удалось прочитать WKT {} geometry: {}", context, e.message) } + .getOrNull() + ?.takeIf { geometry -> !geometry.isEmpty } + } + + if (geometries.isEmpty()) { + return null + } + + return runCatching { + UnaryUnionOp.union(geometries) + }.onFailure { e -> + logger.warn("Не удалось объединить WKT {} geometries: {}", context, e.message) + }.getOrNull() + } + + private fun coveredAreaPercent(totalRequestsArea: Double, coveredArea: Double): Double = + if (totalRequestsArea <= 0.0 || coveredArea <= 0.0) { + 0.0 + } else { + coveredArea / totalRequestsArea * 100.0 + } + + private fun EarthCellWithRequestsDTO.targetId(): Long = if (id > 0) id else num +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanExecutionLockService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanExecutionLockService.kt new file mode 100644 index 0000000..e07e5ce --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanExecutionLockService.kt @@ -0,0 +1,133 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.sql.Connection +import java.util.concurrent.ConcurrentHashMap +import javax.sql.DataSource +import kotlin.system.measureTimeMillis + +@Service +class ComplexPlanExecutionLockService( + private val dataSource: DataSource +) { + + companion object { + private const val LOCK_NAMESPACE = 4_204_202_411L + private val fallbackLocks = ConcurrentHashMap.newKeySet() + } + + private val logger = LoggerFactory.getLogger(this::class.java) + + fun tryAcquire(satelliteIds: Collection): ExecutionLockHandle? { + val normalizedSatelliteIds = satelliteIds.distinct().sorted() + if (normalizedSatelliteIds.isEmpty()) { + logger.info("Complex plan execution lock skipped: empty satellite list") + return ExecutionLockHandle {} + } + + var handle: ExecutionLockHandle? = null + val durationMs = measureTimeMillis { + val connection = dataSource.connection + handle = try { + val postgres = connection.metaData.databaseProductName.contains("PostgreSQL", ignoreCase = true) + if (postgres) { + acquirePostgresLocks(connection, normalizedSatelliteIds) + } else { + connection.close() + acquireFallbackLocks(normalizedSatelliteIds) + } + } catch (ex: Exception) { + runCatching { connection.close() } + throw ex + } + } + logger.info( + "Complex plan execution lock acquire completed: satellites={}, acquired={}, durationMs={}", + normalizedSatelliteIds, + handle != null, + durationMs + ) + return handle + } + + private fun acquirePostgresLocks(connection: Connection, satelliteIds: List): ExecutionLockHandle? { + val acquiredKeys = mutableListOf() + try { + satelliteIds.forEach { satelliteId -> + val lockKey = advisoryKey(satelliteId) + if (!tryAcquirePostgresLock(connection, lockKey)) { + releasePostgresLocks(connection, acquiredKeys.asReversed()) + connection.close() + logger.info( + "Complex plan execution lock busy: satelliteId={}, acquiredSatelliteIds={}", + satelliteId, + acquiredKeys.map(::satelliteIdFromKey) + ) + return null + } + acquiredKeys += lockKey + } + } catch (ex: Exception) { + releasePostgresLocks(connection, acquiredKeys.asReversed()) + connection.close() + throw ex + } + + return ExecutionLockHandle { + releasePostgresLocks(connection, acquiredKeys.asReversed()) + connection.close() + } + } + + private fun acquireFallbackLocks(satelliteIds: List): ExecutionLockHandle? { + val acquiredKeys = mutableListOf() + satelliteIds.forEach { satelliteId -> + val lockKey = advisoryKey(satelliteId) + if (!fallbackLocks.add(lockKey)) { + acquiredKeys.forEach(fallbackLocks::remove) + logger.info( + "Complex plan execution fallback lock busy: satelliteId={}, acquiredSatelliteIds={}", + satelliteId, + acquiredKeys.map(::satelliteIdFromKey) + ) + return null + } + acquiredKeys += lockKey + } + + return ExecutionLockHandle { + acquiredKeys.forEach(fallbackLocks::remove) + } + } + + private fun tryAcquirePostgresLock(connection: Connection, lockKey: Long): Boolean = + connection.prepareStatement("select pg_try_advisory_lock(?)").use { statement -> + statement.setLong(1, lockKey) + statement.executeQuery().use { resultSet -> + resultSet.next() + resultSet.getBoolean(1) + } + } + + private fun releasePostgresLocks(connection: Connection, lockKeys: List) { + lockKeys.forEach { lockKey -> + runCatching { + connection.prepareStatement("select pg_advisory_unlock(?)").use { statement -> + statement.setLong(1, lockKey) + statement.executeQuery().use { resultSet -> resultSet.next() } + } + }.onFailure { ex -> + logger.warn("Failed to release complex plan advisory lock: key={}", lockKey, ex) + } + } + } + + private fun advisoryKey(satelliteId: Long): Long = LOCK_NAMESPACE * 1_000_003L + satelliteId + + private fun satelliteIdFromKey(lockKey: Long): Long = lockKey - LOCK_NAMESPACE * 1_000_003L + + fun interface ExecutionLockHandle : AutoCloseable { + override fun close() + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanPersistenceService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanPersistenceService.kt new file mode 100644 index 0000000..063fe70 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanPersistenceService.kt @@ -0,0 +1,99 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@Service +class ComplexPlanPersistenceService( + private val satelliteModePersistenceService: SatelliteModePersistenceService, + private val complexPlanRunRepository: ComplexPlanRunRepository +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + fun clearModesForInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ) { + val durationMs = measureTimeMillis { + satelliteModePersistenceService.deleteModesForInterval( + satelliteIds = satelliteIds, + intervalStart = intervalStart, + intervalEnd = intervalEnd + ) + } + logger.info( + "Complex plan persistence clear-modes completed: satellites={}, interval=[{} - {}], durationMs={}", + satelliteIds.distinct(), + intervalStart, + intervalEnd, + durationMs + ) + } + + fun saveModesForInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + surveysBySatelliteId: Map>, + complexPlanRunId: Long? = null + ): SatelliteModeSaveResult { + lateinit var result: SatelliteModeSaveResult + val durationMs = measureTimeMillis { + result = satelliteModePersistenceService.saveModesForInterval( + satelliteIds = satelliteIds, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = surveysBySatelliteId, + complexPlanRun = complexPlanRunId?.let { complexPlanRunRepository.getReferenceById(it) } + ) + } + logger.info( + "Complex plan persistence save-modes completed: runId={}, satellites={}, modes={}, durationMs={}", + complexPlanRunId, + satelliteIds.distinct(), + result.calculatedModesCount, + durationMs + ) + return result + } + + fun replaceModesForInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + surveysBySatelliteId: Map>, + complexPlanRunId: Long? = null + ): SatelliteModeSaveResult { + lateinit var result: SatelliteModeSaveResult + val durationMs = measureTimeMillis { + result = satelliteModePersistenceService.replaceModesForInterval( + satelliteIds = satelliteIds, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = surveysBySatelliteId, + complexPlanRun = complexPlanRunId?.let { complexPlanRunRepository.getReferenceById(it) } + ) + } + logger.info( + "Complex plan persistence replace-modes completed: runId={}, satellites={}, modes={}, durationMs={}", + complexPlanRunId, + satelliteIds.distinct(), + result.calculatedModesCount, + durationMs + ) + return result + } + + fun clearAll() { + val durationMs = measureTimeMillis { + satelliteModePersistenceService.clearAll() + } + logger.info("Complex plan persistence clear-all completed: durationMs={}", durationMs) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanQueryService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanQueryService.kt new file mode 100644 index 0000000..9b1116e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanQueryService.kt @@ -0,0 +1,93 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionStatisticsDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO +import java.time.LocalDateTime + +@Service +class ComplexPlanQueryService( + private val satelliteModeQueryService: SatelliteModeQueryService +) { + + fun findMissionModes(satelliteId: Long): List = + satelliteModeQueryService.findMissionModes(satelliteId) + + fun missionStatistics(satelliteId: Long): MissionStatisticsDTO = + satelliteModeQueryService.missionStatistics(satelliteId) + + fun buildPaintGeometry(satelliteId: Long): String = + satelliteModeQueryService.buildPaintGeometry(satelliteId) + + fun buildCellPaintGeometry(cellNum: Long): String = + satelliteModeQueryService.buildCellPaintGeometry(cellNum) + + fun findModesByInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + runId: Long? = null + ): List = + satelliteModeQueryService.findModesByInterval(satelliteIds, intervalStart, intervalEnd, runId) + + fun findSnapshot(snapshotId: Long): SatelliteModeSnapshotResponseDTO = + satelliteModeQueryService.findSnapshot(snapshotId) + + fun findSnapshots( + satelliteId: Long?, + intervalStart: LocalDateTime?, + intervalEnd: LocalDateTime?, + activeOnly: Boolean + ): List = + satelliteModeQueryService.findSnapshots( + satelliteId = satelliteId, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + activeOnly = activeOnly + ) + + fun findActiveSnapshot( + satelliteId: Long, + intervalStart: LocalDateTime? = null, + intervalEnd: LocalDateTime? = null + ): SatelliteModeSnapshotResponseDTO? = + satelliteModeQueryService.findActiveSnapshot(satelliteId, intervalStart, intervalEnd) + + fun findModesBySnapshot( + snapshotId: Long, + intervalStart: LocalDateTime? = null, + intervalEnd: LocalDateTime? = null + ): List = + satelliteModeQueryService.findModesBySnapshot(snapshotId, intervalStart, intervalEnd) + + fun loadWorkingModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> = + satelliteModeQueryService.loadWorkingModes(satelliteIds, intervalStart, intervalEnd) + + fun loadConstraintModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> = + satelliteModeQueryService.loadConstraintModes(satelliteIds, intervalStart, intervalEnd) + + fun loadDayConstraintModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> = + satelliteModeQueryService.loadDayConstraintModes(satelliteIds, intervalStart, intervalEnd) + + fun loadRevolutionConstraintModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> = + satelliteModeQueryService.loadRevolutionConstraintModes(satelliteIds, intervalStart, intervalEnd) +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt new file mode 100644 index 0000000..7d6bfd1 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt @@ -0,0 +1,82 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +@Component +class ComplexPlanRunMapper { + + fun toResponse(entity: ComplexPlanRunEntity): ComplexPlanRunResponseDTO = + ComplexPlanRunResponseDTO( + runId = entity.id ?: 0, + status = entity.status, + intervalStart = entity.intervalStart, + intervalEnd = entity.intervalEnd, + satelliteIds = satelliteIds(entity), + requestedBy = entity.requestedBy, + createdAt = entity.createdAt.toUtcOffset(), + startedAt = entity.startedAt?.toUtcOffset(), + finishedAt = entity.finishedAt?.toUtcOffset(), + durationMs = entity.durationMs, + errorMessage = entity.errorMessage, + calculatedModesCount = entity.calculatedModesCount, + bookedModesCount = entity.bookedModesCount, + complanModesCount = entity.complanModesCount, + mixedModesCount = entity.mixedModesCount, + affectedSatellitesCount = entity.affectedSatellitesCount, + processedCellsCount = entity.processedCellsCount, + bookedSlotLinksCount = entity.bookedSlotLinksCount, + snapshotIds = snapshotIds(entity), + requestPayload = entity.requestPayload, + totalRequestsArea = entity.totalRequestsArea, + coveredArea = entity.coveredArea, + coveredAreaPercent = entity.coveredAreaPercent + ) + + fun toSummary(entity: ComplexPlanRunEntity): ComplexPlanRunSummaryDTO = + ComplexPlanRunSummaryDTO( + runId = entity.id ?: 0, + status = entity.status, + intervalStart = entity.intervalStart, + intervalEnd = entity.intervalEnd, + satelliteIds = satelliteIds(entity), + requestedBy = entity.requestedBy, + createdAt = entity.createdAt.toUtcOffset(), + startedAt = entity.startedAt?.toUtcOffset(), + finishedAt = entity.finishedAt?.toUtcOffset(), + durationMs = entity.durationMs, + errorMessage = entity.errorMessage, + calculatedModesCount = entity.calculatedModesCount, + bookedModesCount = entity.bookedModesCount, + complanModesCount = entity.complanModesCount, + mixedModesCount = entity.mixedModesCount, + affectedSatellitesCount = entity.affectedSatellitesCount, + processedCellsCount = entity.processedCellsCount, + bookedSlotLinksCount = entity.bookedSlotLinksCount, + snapshotIds = snapshotIds(entity), + totalRequestsArea = entity.totalRequestsArea, + coveredArea = entity.coveredArea, + coveredAreaPercent = entity.coveredAreaPercent + ) + + private fun satelliteIds(entity: ComplexPlanRunEntity): List = + entity.satellites + .map { it.satelliteId } + .distinct() + .sorted() + + private fun snapshotIds(entity: ComplexPlanRunEntity): List = + entity.snapshots + .mapNotNull { it.id } + .distinct() + .sorted() + + private fun LocalDateTime.toUtcOffset(): OffsetDateTime = + atOffset(ZoneOffset.UTC) + +} 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 new file mode 100644 index 0000000..32665c6 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt @@ -0,0 +1,191 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +import tools.jackson.databind.json.JsonMapper +import kotlin.system.measureTimeMillis + +@Service +class ComplexPlanRunOrchestrationService( + private val complexPlanRunPersistenceService: ComplexPlanRunPersistenceService, + private val complexPlanRunQueryService: ComplexPlanRunQueryService, + private val complexPlanCalculationService: ComplexPlanCalculationService, + private val satelliteService: SatelliteService, + private val complexPlanExecutionLockService: ComplexPlanExecutionLockService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = JsonMapper.builder().findAndAddModules().build() + + fun process(request: ComplexPlanProcessRequestDTO, requestedBy: String?): ComplexPlanRunResponseDTO { + lateinit var selectedSatelliteIds: List + val resolveSatelliteIdsMs = measureTimeMillis { + selectedSatelliteIds = resolveSatelliteIds(request) + } + logger.info( + "Complex plan orchestration stage resolve-satellites completed: satellites={}, durationMs={}", + selectedSatelliteIds, + resolveSatelliteIdsMs + ) + + lateinit var createdRun: ComplexPlanRunEntity + val createRunMs = measureTimeMillis { + createdRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = selectedSatelliteIds, + requestedBy = requestedBy, + requestPayload = objectMapper.writeValueAsString(request) + ) + ) + } + val runId = createdRun.id ?: error("Complex plan run id is null after create") + logger.info( + "Complex plan orchestration stage create-run completed: runId={}, durationMs={}", + runId, + createRunMs + ) + + var startupLock: ComplexPlanExecutionLockService.ExecutionLockHandle? = null + val acquireLockMs = measureTimeMillis { + startupLock = complexPlanExecutionLockService.tryAcquire(selectedSatelliteIds) + } + logger.info( + "Complex plan orchestration stage acquire-lock completed: runId={}, acquired={}, durationMs={}", + runId, + startupLock != null, + acquireLockMs + ) + if (startupLock == null) { + val errorMessage = "Complex plan recalculation is already running for at least one requested satellite" + complexPlanRunPersistenceService.markFailed(runId, errorMessage) + throw CustomValidationException(errorMessage) + } + + try { + lateinit var overlappingRunIds: List + val overlapCheckMs = measureTimeMillis { + overlappingRunIds = complexPlanRunPersistenceService.findOverlappingRunningRunIds( + satelliteIds = selectedSatelliteIds, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + excludedRunId = runId + ) + } + logger.info( + "Complex plan orchestration stage overlap-check completed: runId={}, overlaps={}, durationMs={}", + runId, + overlappingRunIds, + overlapCheckMs + ) + if (overlappingRunIds.isNotEmpty()) { + val errorMessage = + "Complex plan recalculation is already running for overlapping interval, runIds=$overlappingRunIds" + complexPlanRunPersistenceService.markFailed(runId, errorMessage) + throw CustomValidationException(errorMessage) + } + + val markRunningMs = measureTimeMillis { + complexPlanRunPersistenceService.markRunning(runId) + } + logger.info( + "Complex plan orchestration stage mark-running completed: runId={}, durationMs={}", + runId, + markRunningMs + ) + } finally { + startupLock?.close() + } + + val result = try { + lateinit var calculationResult: ComplexPlanCalculationResult + val calculationMs = measureTimeMillis { + calculationResult = complexPlanCalculationService.process( + runId = runId, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = selectedSatelliteIds, + sun = request.sun, + coverageSource = request.coverageSource ?: CoverageCalculationSource.SLOTS, + countLat = request.countLat, + countLong = request.countLong, + coverageStrategy = request.coverageStrategy + ) + } + logger.info( + "Complex plan orchestration stage calculation completed: runId={}, calculatedModes={}, durationMs={}", + runId, + calculationResult.calculatedModesCount, + calculationMs + ) + calculationResult + } catch (ex: Exception) { + complexPlanRunPersistenceService.markFailed(runId, ex.message ?: ex.javaClass.simpleName) + logger.error("Complex plan execution failed: runId={}", runId, ex) + throw ex + } + val markCompletedMs = measureTimeMillis { + complexPlanRunPersistenceService.markCompleted( + runId = runId, + statistics = result.toStatistics() + ) + } + logger.info( + "Complex plan orchestration stage mark-completed completed: runId={}, durationMs={}", + runId, + markCompletedMs + ) + + lateinit var response: ComplexPlanRunResponseDTO + val loadResponseMs = measureTimeMillis { + response = complexPlanRunQueryService.getRun(runId) + } + logger.info( + "Complex plan orchestration stage load-response completed: runId={}, durationMs={}", + runId, + loadResponseMs + ) + return response + } + + private fun resolveSatelliteIds(request: ComplexPlanProcessRequestDTO): List = + buildList { + request.satelliteId?.let { add(it) } + addAll(request.satelliteIds) + }.distinct().ifEmpty { satelliteService.satellites.map { it.satelliteId }.distinct() } +} + +data class ComplexPlanCalculationResult( + val calculatedModesCount: Int = 0, + val bookedModesCount: Int = 0, + val complanModesCount: Int = 0, + val mixedModesCount: Int = 0, + val affectedSatellitesCount: Int = 0, + val processedCellsCount: Int = 0, + val bookedSlotLinksCount: Int = 0, + val totalRequestsArea: Double = 0.0, + val coveredArea: Double = 0.0, + val coveredAreaPercent: Double = 0.0 +) { + fun toStatistics(): ComplexPlanRunStatisticsDTO = + ComplexPlanRunStatisticsDTO( + calculatedModesCount = calculatedModesCount, + bookedModesCount = bookedModesCount, + complanModesCount = complanModesCount, + mixedModesCount = mixedModesCount, + affectedSatellitesCount = affectedSatellitesCount, + processedCellsCount = processedCellsCount, + bookedSlotLinksCount = bookedSlotLinksCount, + totalRequestsArea = totalRequestsArea, + coveredArea = coveredArea, + coveredAreaPercent = coveredAreaPercent + ) +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunPersistenceService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunPersistenceService.kt new file mode 100644 index 0000000..e1984e3 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunPersistenceService.kt @@ -0,0 +1,238 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunSatelliteEntity +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import java.time.Clock +import java.time.Duration +import java.time.LocalDateTime + +@Service +class ComplexPlanRunPersistenceService( + private val complexPlanRunRepository: ComplexPlanRunRepository, + private val clock: Clock +) { + + companion object { + const val APPLICATION_SHUTDOWN_ERROR_MESSAGE = "Run marked as FAILED because the application was shutting down" + const val STARTUP_RECOVERY_ERROR_MESSAGE = "Marked as FAILED on startup: previous process terminated before completion" + } + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun createRun(createDTO: ComplexPlanRunCreateDTO): ComplexPlanRunEntity { + validateInterval(createDTO.intervalStart, createDTO.intervalEnd) + val now = LocalDateTime.now(clock) + val entity = ComplexPlanRunEntity( + status = ComplexPlanRunStatus.CREATED, + createdAt = now, + intervalStart = createDTO.intervalStart, + intervalEnd = createDTO.intervalEnd, + requestedBy = createDTO.requestedBy, + requestPayload = createDTO.requestPayload + ) + entity.satellites = createDTO.satelliteIds + .distinct() + .sorted() + .map { satelliteId -> + ComplexPlanRunSatelliteEntity( + run = entity, + satelliteId = satelliteId + ) + } + .toMutableList() + + val saved = complexPlanRunRepository.save(entity) + logger.info( + "complexPlanRun created: runId={}, satellites={}, interval=[{} - {}], requestedBy={}", + saved.id, + saved.satellites.map { it.satelliteId }, + saved.intervalStart, + saved.intervalEnd, + saved.requestedBy + ) + return saved + } + + @Transactional + fun markRunning(runId: Long): ComplexPlanRunEntity { + val entity = getRunOrThrow(runId) + entity.status = ComplexPlanRunStatus.RUNNING + entity.startedAt = LocalDateTime.now(clock) + entity.finishedAt = null + entity.durationMs = null + entity.errorMessage = null + logger.info("complexPlanRun started: runId={}, startedAt={}", runId, entity.startedAt) + return entity + } + + @Transactional + fun markCompleted(runId: Long, statistics: ComplexPlanRunStatisticsDTO): ComplexPlanRunEntity { + val entity = getRunOrThrow(runId) + entity.status = ComplexPlanRunStatus.COMPLETED + entity.finishedAt = LocalDateTime.now(clock) + entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt) + entity.errorMessage = null + applyStatistics(entity, statistics) + logger.info( + "complexPlanRun completed: runId={}, durationMs={}, calculatedModes={}, bookedModes={}, complanModes={}, mixedModes={}, satellites={}, processedCells={}, bookedSlotLinks={}", + runId, + entity.durationMs, + entity.calculatedModesCount, + entity.bookedModesCount, + entity.complanModesCount, + entity.mixedModesCount, + entity.affectedSatellitesCount, + entity.processedCellsCount, + entity.bookedSlotLinksCount + ) + return entity + } + + @Transactional + fun markFailed(runId: Long, errorMessage: String?): ComplexPlanRunEntity { + val entity = getRunOrThrow(runId) + entity.status = ComplexPlanRunStatus.FAILED + entity.finishedAt = LocalDateTime.now(clock) + entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt) + entity.errorMessage = errorMessage?.take(4000) + logger.info( + "complexPlanRun failed: runId={}, durationMs={}, error={}", + runId, + entity.durationMs, + entity.errorMessage + ) + return entity + } + + @Transactional + fun markActiveRunsFailedOnShutdown( + errorMessage: String = APPLICATION_SHUTDOWN_ERROR_MESSAGE + ): Int { + val interruptedStatuses = listOf( + ComplexPlanRunStatus.CREATED, + ComplexPlanRunStatus.RUNNING + ) + val now = LocalDateTime.now(clock) + val interruptedRuns = complexPlanRunRepository.findAllByStatusIn(interruptedStatuses) + + interruptedRuns.forEach { entity -> + entity.status = ComplexPlanRunStatus.FAILED + entity.finishedAt = now + entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt) + entity.errorMessage = errorMessage.take(4000) + } + + if (interruptedRuns.isNotEmpty()) { + logger.info( + "complexPlanRun shutdown-fail: affectedRuns={}, runIds={}", + interruptedRuns.size, + interruptedRuns.mapNotNull { it.id }.sorted() + ) + } + + return interruptedRuns.size + } + + @Transactional + fun markRunningRunsFailedOnStartup( + errorMessage: String = STARTUP_RECOVERY_ERROR_MESSAGE + ): Int { + val now = LocalDateTime.now(clock) + val staleRuns = complexPlanRunRepository.findAllByStatusIn(listOf(ComplexPlanRunStatus.RUNNING)) + + staleRuns.forEach { entity -> + entity.status = ComplexPlanRunStatus.FAILED + entity.finishedAt = entity.finishedAt ?: now + entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt) + entity.errorMessage = errorMessage.take(4000) + } + + if (staleRuns.isNotEmpty()) { + logger.info( + "complexPlanRun startup-recovery: affectedRuns={}, runIds={}", + staleRuns.size, + staleRuns.mapNotNull { it.id }.sorted() + ) + } + + return staleRuns.size + } + + @Transactional(readOnly = true) + fun getRun(runId: Long): ComplexPlanRunEntity { + val entity = complexPlanRunRepository.findDetailedById(runId) + ?: throw CustomValidationException("Complex plan run $runId not found") + entity.snapshots.size + return entity + } + + @Transactional(readOnly = true) + fun findOverlappingRunningRunIds( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + excludedRunId: Long + ): List { + validateInterval(intervalStart, intervalEnd) + if (satelliteIds.isEmpty()) { + return emptyList() + } + + return complexPlanRunRepository.findOverlappingRunIdsByStatusAndSatelliteIds( + status = ComplexPlanRunStatus.RUNNING, + satelliteIds = satelliteIds.distinct(), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + excludedRunId = excludedRunId + ) + } + + @Transactional + fun deleteRun(runId: Long) { + if (!complexPlanRunRepository.existsById(runId)) { + throw CustomValidationException("Complex plan run $runId not found") + } + + complexPlanRunRepository.deleteById(runId) + logger.info("complexPlanRun deleted: runId={}", runId) + } + + private fun getRunOrThrow(runId: Long): ComplexPlanRunEntity = + complexPlanRunRepository.findById(runId) + .orElseThrow { CustomValidationException("Complex plan run $runId not found") } + + private fun applyStatistics(entity: ComplexPlanRunEntity, statistics: ComplexPlanRunStatisticsDTO) { + entity.calculatedModesCount = statistics.calculatedModesCount + entity.bookedModesCount = statistics.bookedModesCount + entity.complanModesCount = statistics.complanModesCount + entity.mixedModesCount = statistics.mixedModesCount + entity.affectedSatellitesCount = statistics.affectedSatellitesCount + entity.processedCellsCount = statistics.processedCellsCount + entity.bookedSlotLinksCount = statistics.bookedSlotLinksCount + entity.totalRequestsArea = statistics.totalRequestsArea + entity.coveredArea = statistics.coveredArea + entity.coveredAreaPercent = statistics.coveredAreaPercent + } + + private fun calculateDurationMs(startedAt: LocalDateTime?, finishedAt: LocalDateTime?): Long? { + if (startedAt == null || finishedAt == null) { + return null + } + return Duration.between(startedAt, finishedAt).toMillis().coerceAtLeast(0) + } + + private fun validateInterval(intervalStart: LocalDateTime, intervalEnd: LocalDateTime) { + if (intervalEnd.isBefore(intervalStart)) { + throw CustomValidationException("Параметр intervalEnd должен быть больше или равен intervalStart") + } + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunQueryService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunQueryService.kt new file mode 100644 index 0000000..4a20216 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunQueryService.kt @@ -0,0 +1,82 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import jakarta.persistence.criteria.JoinType +import org.slf4j.LoggerFactory +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunFilterDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import kotlin.system.measureTimeMillis + +@Service +class ComplexPlanRunQueryService( + private val complexPlanRunRepository: ComplexPlanRunRepository, + private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository, + private val complexPlanRunMapper: ComplexPlanRunMapper +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional(readOnly = true) + fun getRun(runId: Long): ComplexPlanRunResponseDTO { + lateinit var response: ComplexPlanRunResponseDTO + val durationMs = measureTimeMillis { + val run = complexPlanRunRepository.findDetailedById(runId) + ?: throw CustomValidationException("Complex plan run $runId not found") + run.snapshots = satelliteModeSnapshotRepository.findAllByComplexPlanRun_IdOrderByIdAsc(runId).toMutableList() + response = complexPlanRunMapper.toResponse(run) + } + logger.info( + "complexPlanRun response loaded: runId={}, status={}, snapshots={}, durationMs={}", + runId, + response.status, + response.snapshotIds.size, + durationMs + ) + return response + } + + @Transactional(readOnly = true) + fun findRuns(filter: ComplexPlanRunFilterDTO): List = + complexPlanRunRepository.findAll( + buildSpecification(filter), + Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ) + ).map(complexPlanRunMapper::toSummary) + + private fun buildSpecification(filter: ComplexPlanRunFilterDTO): Specification = + Specification { root, query, criteriaBuilder -> + val predicates = mutableListOf() + query.distinct(true) + + filter.status?.let { status -> + predicates += criteriaBuilder.equal(root.get("status"), status) + } + filter.createdFrom?.let { createdFrom -> + predicates += criteriaBuilder.greaterThanOrEqualTo(root.get("createdAt"), createdFrom) + } + filter.createdTo?.let { createdTo -> + predicates += criteriaBuilder.lessThanOrEqualTo(root.get("createdAt"), createdTo) + } + filter.intervalStart?.let { intervalStart -> + predicates += criteriaBuilder.greaterThanOrEqualTo(root.get("intervalStart"), intervalStart) + } + filter.intervalEnd?.let { intervalEnd -> + predicates += criteriaBuilder.lessThanOrEqualTo(root.get("intervalEnd"), intervalEnd) + } + filter.satelliteId?.let { satelliteId -> + val satellitesJoin = root.join("satellites", JoinType.LEFT) + predicates += criteriaBuilder.equal(satellitesJoin.get("satelliteId"), satelliteId) + } + + criteriaBuilder.and(*predicates.toTypedArray()) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunShutdownHandler.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunShutdownHandler.kt new file mode 100644 index 0000000..c690f78 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunShutdownHandler.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.dao.DataAccessException +import org.springframework.context.event.ContextClosedEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class ComplexPlanRunShutdownHandler( + private val complexPlanRunPersistenceService: ComplexPlanRunPersistenceService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @EventListener(ContextClosedEvent::class) + fun onContextClosed() { + try { + val updatedRuns = complexPlanRunPersistenceService.markActiveRunsFailedOnShutdown() + if (updatedRuns > 0) { + logger.info("complexPlanRun shutdown handler completed: updatedRuns={}", updatedRuns) + } + } catch (ex: DataAccessException) { + logger.warn("complexPlanRun shutdown handler skipped due to data access issue: {}", ex.message) + } + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunStartupRecovery.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunStartupRecovery.kt new file mode 100644 index 0000000..b30d2a8 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunStartupRecovery.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.dao.DataAccessException +import org.springframework.stereotype.Component + +@Component +class ComplexPlanRunStartupRecovery( + private val complexPlanRunPersistenceService: ComplexPlanRunPersistenceService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @EventListener(ApplicationReadyEvent::class) + fun onApplicationReady() { + try { + val recoveredRuns = complexPlanRunPersistenceService.markRunningRunsFailedOnStartup() + if (recoveredRuns > 0) { + logger.info("complexPlanRun startup recovery completed: recoveredRuns={}", recoveredRuns) + } + } catch (ex: DataAccessException) { + logger.warn("complexPlanRun startup recovery skipped due to data access issue: {}", ex.message) + } + } +} 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 new file mode 100644 index 0000000..cb741ab --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt @@ -0,0 +1,250 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClientRequestException +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToFlux +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SatelliteMissionBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SatelliteMissionBatchResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchResponseDTO +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 tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@Service +class CoverageBatchClient(webClientBuilderProvider: ObjectProvider) : CoverageCalculationClient { + + override val source: CoverageCalculationSource = CoverageCalculationSource.SLOTS + + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val logger = LoggerFactory.getLogger(this::class.java) + + private data class CoverageBatchCallResult( + val response: List, + val durationMs: Long, + val coverageStrategy: SlotCoverageStrategy + ) + + private class CoverageBatchRemoteException( + val statusCode: Int, + message: String + ) : RuntimeException(message) + + @Value("\${settings.slots-service:slots-service}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + fun plannedModes( + satelliteIds: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> { + if (satelliteIds.isEmpty()) { + return emptyMap() + } + + val selectedSatelliteIds = satelliteIds.distinct() + logger.info( + "slots-service plannedModes batch start: satellites={}, interval=[{} - {}]", + selectedSatelliteIds.size, + intervalStart, + intervalEnd + ) + + var response = emptyList() + val durationMs = measureTimeMillis { + response = client() + .post() + .uri("/api/slots/mission/satellites") + .bodyValue( + SatelliteMissionBatchRequestDTO( + satelliteIds = selectedSatelliteIds, + timeStart = intervalStart, + timeStop = intervalEnd + ) + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux() + .collectList() + .block() + .orEmpty() + } + + logger.info( + "slots-service plannedModes batch finish: satellites={}, chains={}, durationMs={}", + selectedSatelliteIds.size, + response.sumOf { it.slots.size }, + durationMs + ) + + return response.associate { it.satelliteId to it.slots } + } + + override fun coverageModes( + targets: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + satelliteIds: List, + occupiedIntervals: List, + sun: Double?, + coverageStrategy: SlotCoverageStrategy? + ): Map> { + if (targets.isEmpty() || satelliteIds.isEmpty()) { + return emptyMap() + } + + val selectedSatelliteIds = satelliteIds.distinct() + logger.info( + "slots-service coverage batch start: targets={}, satellites={}, occupiedIntervals={}, interval=[{} - {}], sun={}, coverageStrategy={}", + targets.size, + selectedSatelliteIds.size, + occupiedIntervals.size, + intervalStart, + intervalEnd, + sun, + coverageStrategy + ) + + val effectiveStrategy = coverageStrategy ?: SlotCoverageStrategy.GREEDY + + val result = try { + requestCoverageModes( + targets = targets, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + selectedSatelliteIds = selectedSatelliteIds, + occupiedIntervals = occupiedIntervals, + sun = sun, + coverageStrategy = effectiveStrategy + ) + } catch (ex: Exception) { + if (effectiveStrategy == SlotCoverageStrategy.CONTINUOUS && isContinuousFallbackCandidate(ex)) { + logger.warn( + "slots-service coverage batch CONTINUOUS failed, fallback to GREEDY: targets={}, satellites={}, occupiedIntervals={}, interval=[{} - {}], causeType={}, cause={}", + targets.size, + selectedSatelliteIds.size, + occupiedIntervals.size, + intervalStart, + intervalEnd, + ex::class.simpleName, + ex.message + ) + requestCoverageModes( + targets = targets, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + selectedSatelliteIds = selectedSatelliteIds, + occupiedIntervals = occupiedIntervals, + sun = sun, + coverageStrategy = SlotCoverageStrategy.GREEDY + ) + } else { + throw toCustomError(ex) + } + } + + logger.info( + "slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}, effectiveCoverageStrategy={}", + targets.size, + result.response.size, + result.response.sumOf { it.slots.size }, + result.durationMs, + result.coverageStrategy + ) + + return result.response.associate { it.targetId to it.slots } + } + + private fun requestCoverageModes( + targets: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + selectedSatelliteIds: List, + occupiedIntervals: List, + sun: Double?, + coverageStrategy: SlotCoverageStrategy + ): CoverageBatchCallResult { + var response = emptyList() + val durationMs = measureTimeMillis { + response = client() + .post() + .uri("/api/slots/poly-cover/batch") + .bodyValue( + SlotCoverageBatchRequestDTO( + items = targets, + timeStart = intervalStart, + timeStop = intervalEnd, + cov = true, + satellites = selectedSatelliteIds, + occupiedIntervals = occupiedIntervals, + sun = sun, + coverageStrategy = coverageStrategy + ) + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + val statusCode = response.statusCode().value() + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> + Mono.error(CoverageBatchRemoteException(statusCode, extractErrorMessage(body))) + } + } + .bodyToFlux() + .collectList() + .block() + .orEmpty() + } + return CoverageBatchCallResult( + response = response, + durationMs = durationMs, + coverageStrategy = coverageStrategy + ) + } + + private fun isContinuousFallbackCandidate(ex: Exception): Boolean = + when (ex) { + is CoverageBatchRemoteException -> ex.statusCode >= 500 + is WebClientRequestException -> true + else -> false + } + + private fun toCustomError(ex: Exception): CustomErrorException = + when (ex) { + is CustomErrorException -> ex + is CoverageBatchRemoteException -> CustomErrorException(ex.message ?: "error") + else -> CustomErrorException(ex.message ?: "error") + } + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageCalculationClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageCalculationClient.kt new file mode 100644 index 0000000..72dae35 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageCalculationClient.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +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 java.time.LocalDateTime + +interface CoverageCalculationClient { + val source: CoverageCalculationSource + + fun coverageModes( + targets: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + satelliteIds: List, + occupiedIntervals: List = emptyList(), + sun: Double? = null, + coverageStrategy: SlotCoverageStrategy? = null + ): Map> +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageCalculationClientResolver.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageCalculationClientResolver.kt new file mode 100644 index 0000000..be7503e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageCalculationClientResolver.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource + +@Service +class CoverageCalculationClientResolver( + clients: List +) { + private val clientsBySource = clients.associateBy { it.source } + + fun resolve(source: CoverageCalculationSource): CoverageCalculationClient = + clientsBySource[source] + ?: throw CustomValidationException("Unsupported coverage calculation source: $source") +} 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 new file mode 100644 index 0000000..7527735 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt @@ -0,0 +1,148 @@ +package space.nstart.pcp.pcp_satellites_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_satellites_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +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 tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@Service +class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider) : CoverageCalculationClient { + + override val source: CoverageCalculationSource = CoverageCalculationSource.COVERAGE_SCHEME + + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val logger = LoggerFactory.getLogger(this::class.java) + + @Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + override fun coverageModes( + targets: List, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + satelliteIds: List, + occupiedIntervals: List, + sun: Double?, + coverageStrategy: SlotCoverageStrategy? + ): Map> { + if (targets.isEmpty() || satelliteIds.isEmpty()) { + return emptyMap() + } + + val selectedSatelliteIds = satelliteIds.distinct() + + logger.info( + "coverage-scheme-service coverage start: targets={}, satellites={}, occupiedIntervals={}, interval=[{} - {}]", + targets.size, + selectedSatelliteIds.size, + occupiedIntervals.size, + intervalStart, + intervalEnd + ) + + val result = linkedMapOf>() + val durationMs = measureTimeMillis { + targets.forEach { target -> + lateinit var response: CoverageSchemeResponseDTO + val targetDurationMs = measureTimeMillis { + response = calculateTarget( + target = target, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = selectedSatelliteIds, + sun = sun + ) + } + result[target.targetId] = response.coverageScheme.map { it.toSlotDTO() } + logger.info( + "coverage-scheme-service target calculated: targetId={}, candidateSlots={}, durationMs={}", + target.targetId, + response.coverageScheme.size, + targetDurationMs + ) + } + } + + logger.info( + "coverage-scheme-service coverage finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}", + targets.size, + result.size, + result.values.sumOf { it.size }, + durationMs + ) + + return result + } + + private fun calculateTarget( + target: SlotCoverageTargetDTO, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + satelliteIds: List, + sun: Double? + ): CoverageSchemeResponseDTO = + client() + .post() + .uri("/api/coverage-schemes/calculate") + .bodyValue( + CoverageSchemeCalculateRequestDTO( + polygonWkt = target.wkt, + satelliteIds = satelliteIds, + timeStart = intervalStart, + timeEnd = intervalEnd, + sunAngle = sun + ) + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(CoverageSchemeResponseDTO::class.java) + .block() + ?: CoverageSchemeResponseDTO() + + private fun SurveySlotDTO.toSlotDTO() = + SlotDTO( + satelliteId = satelliteId, + tn = tn, + tk = tk, + roll = roll, + contour = contour, + revolution = revolution, + revolutionSign = revolutionSign, + latitude = latitude, + longitude = longitude + ) + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/EarthGridService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/EarthGridService.kt new file mode 100644 index 0000000..725fefb --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/EarthGridService.kt @@ -0,0 +1,100 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO +import java.util.UUID +import kotlin.system.measureTimeMillis + + +@Service +/** + * Request/grid reader backed by pcp-request-service /v1 cells API. + */ +class EarthGridService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val logger = LoggerFactory.getLogger(this::class.java) + + @Value("\${settings.request-service:\${settings.earth-grid-service:pcp-request-service}}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + fun cells(countLat: Int? = null, countLong: Int? = null): Iterable { + var cells = emptyList() + val durationMs = measureTimeMillis { + cells = client() + .get() + .uri(cellsWithRequestsUri(countLat, countLong)) + .retrieve() + .bodyToMono(CellsWithRequestsResponseDto::class.java) + .block() + ?.items + .orEmpty() + .map { cell -> cell.toEarthCellWithRequestsDto() } + } + logger.info( + "request-service v1 cells with requests loaded: cells={}, durationMs={}", + cells.size, + durationMs + ) + return cells + } + + private fun cellsWithRequestsUri(countLat: Int?, countLong: Int?): String { + val params = mutableListOf("minImportance=$REQUEST_GRID_MIN_IMPORTANCE") + countLat?.let { params += "countLat=$it" } + countLong?.let { params += "countLong=$it" } + return "/v1/cells/with-requests?${params.joinToString("&")}" + } + + private fun CellWithRequestsResponseDto.toEarthCellWithRequestsDto(): EarthCellWithRequestsDTO = + EarthCellWithRequestsDTO( + id = cellNum, + num = cellNum, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = contour, + requests = requests.map { request -> request.toCellRequestDto() }, + ) + + private fun CellRequestFragmentResponseDto.toCellRequestDto(): CellRequestDOT = + CellRequestDOT( + requestId = requestId.toString(), + covPercent = coveragePercent, + importance = importance, + contour = contour, + ) + +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CellsWithRequestsResponseDto( + val items: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CellWithRequestsResponseDto( + val cellNum: Long = 0, + val latitude: Double = 0.0, + val longitude: Double = 0.0, + val importance: Double = 0.0, + val contour: String = "", + val requests: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CellRequestFragmentResponseDto( + val requestId: UUID = UUID(0L, 0L), + val contour: String = "", + val coveragePercent: Double = 0.0, + val importance: Double = 0.0, +) + +private const val REQUEST_GRID_MIN_IMPORTANCE = "0.001" diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteCatalogClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteCatalogClient.kt new file mode 100644 index 0000000..575775e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteCatalogClient.kt @@ -0,0 +1,69 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import kotlin.system.measureTimeMillis + +interface SatelliteCatalogClient { + fun allSatellites(): List +} + +@Service +class SatelliteCatalogWebClient( + webClientBuilderProvider: ObjectProvider +) : SatelliteCatalogClient { + + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val logger = LoggerFactory.getLogger(this::class.java) + + @Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}") + private val satelliteCatalogServiceUrl = "" + + override fun allSatellites(): List { + var summaries = emptyList() + val summariesMs = measureTimeMillis { + summaries = webClientBuilder.build() + .get() + .uri("$satelliteCatalogServiceUrl/api/satellites") + .retrieve() + .bodyToFlux(SatelliteSummaryDTO::class.java) + .collectList() + .block() + .orEmpty() + } + logger.info( + "satellite-catalog-service summaries loaded: satellites={}, durationMs={}", + summaries.size, + summariesMs + ) + if (summaries.isEmpty()) { + return emptyList() + } + + var satellites = emptyList() + val detailsMs = measureTimeMillis { + satellites = webClientBuilder.build() + .post() + .uri("$satelliteCatalogServiceUrl/api/satellites/batch") + .bodyValue(SatelliteBatchRequestDTO(ids = summaries.map { it.id })) + .retrieve() + .bodyToFlux(SatelliteDTO::class.java) + .collectList() + .block() + .orEmpty() + } + logger.info( + "satellite-catalog-service details loaded: requested={}, returned={}, durationMs={}", + summaries.size, + satellites.size, + detailsMs + ) + return satellites + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedKafkaListener.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedKafkaListener.kt new file mode 100644 index 0000000..1b24885 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedKafkaListener.kt @@ -0,0 +1,72 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent + +private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-complex-mission-service-satellite-deleted" + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class SatelliteDeletedKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteDeletedService: SatelliteDeletedService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + topics = ["\${app.kafka.topics.satellites:pcp.satellites}"], + groupId = SATELLITE_DELETED_CONSUMER_GROUP + ) + fun consume(message: String) { + try { + val kafkaMessage = objectMapper.readValue( + message, + object : TypeReference>() {} + ) + if (kafkaMessage.type != PcpKafkaEvent.SatelliteDeletedEvent) { + logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type) + return + } + + val event = kafkaMessage.data + logger.info( + "Received SatelliteDeletedEvent in pcp-complex-mission-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + event.deleteIdentifiers().forEach { satelliteId -> + satelliteDeletedService.deleteSatelliteData(satelliteId) + } + logger.info( + "SatelliteDeletedEvent processed in pcp-complex-mission-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + } catch (exception: Exception) { + logger.error("Failed to process SatelliteDeletedEvent in pcp-complex-mission-service", exception) + throw exception + } + } + + private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List = + listOfNotNull(satelliteId, noradId).distinct() +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedService.kt new file mode 100644 index 0000000..9b43c84 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedService.kt @@ -0,0 +1,52 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunSatelliteRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository + +@Service +class SatelliteDeletedService( + private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository, + private val satelliteModeRepository: SatelliteModeRepository, + private val complexPlanRunSatelliteRepository: ComplexPlanRunSatelliteRepository +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun deleteSatelliteData(satelliteId: Long): SatelliteComplexMissionDeletionSummary { + val modesDeletedByCascade = satelliteModeRepository.countBySatelliteId(satelliteId) + val snapshotsBeforeDelete = satelliteModeSnapshotRepository.countBySatelliteId(satelliteId) + val runLinksBeforeDelete = complexPlanRunSatelliteRepository.countBySatelliteId(satelliteId) + + val snapshotsDeleted = satelliteModeSnapshotRepository.deleteAllBySatelliteId(satelliteId) + val runLinksDeleted = complexPlanRunSatelliteRepository.deleteAllBySatelliteId(satelliteId) + val summary = SatelliteComplexMissionDeletionSummary( + satelliteId = satelliteId, + snapshotsDeleted = snapshotsDeleted, + modesDeletedByCascade = modesDeletedByCascade, + runLinksDeleted = runLinksDeleted + ) + + logger.info( + "Deleted complex mission data for satellite: satelliteId={}, snapshotsDeleted={}, snapshotsBeforeDelete={}, modesDeletedByCascade={}, runLinksDeleted={}, runLinksBeforeDelete={}", + summary.satelliteId, + summary.snapshotsDeleted, + snapshotsBeforeDelete, + summary.modesDeletedByCascade, + summary.runLinksDeleted, + runLinksBeforeDelete + ) + return summary + } +} + +data class SatelliteComplexMissionDeletionSummary( + val satelliteId: Long, + val snapshotsDeleted: Long, + val modesDeletedByCascade: Long, + val runLinksDeleted: Long +) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeMapper.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeMapper.kt new file mode 100644 index 0000000..0b4c31c --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeMapper.kt @@ -0,0 +1,140 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.locationtech.jts.io.WKTReader +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeCellEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeType +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO + +@Component +class SatelliteModeMapper { + + fun toEntity( + satelliteId: Long, + survey: SurveyMode, + snapshot: SatelliteModeSnapshotEntity? = null, + complexPlanRun: ComplexPlanRunEntity? = null + ): SatelliteModeEntity { + val (latitude, longitude) = resolveCoordinates(survey) + val entity = SatelliteModeEntity( + satelliteId = satelliteId, + source = survey.source, + type = SatelliteModeType.SURVEY, + startTime = survey.time, + endTime = survey.timeStop, + revolution = survey.revolution, + lat = latitude, + longitude = longitude, + duration = survey.duration, + contourWkt = survey.contourWKT, + roll = survey.roll, + cellNum = survey.primaryCellNum(), + snapshot = snapshot, + complexPlanRun = complexPlanRun + ) + + entity.bookedSlots = survey.bookedSlotIds + .filter { it > 0 } + .distinct() + .map { bookedSlotId -> + SatelliteModeBookedSlotEntity( + mode = entity, + bookedSlotId = bookedSlotId + ) + } + .toMutableList() + + entity.cells = survey.cellNumsForPersistence() + .map { cellNum -> + SatelliteModeCellEntity( + mode = entity, + cellNum = cellNum + ) + } + .toMutableList() + + return entity + } + + fun toSurveyMode( + entity: SatelliteModeEntity, + bookedSlotIds: List = emptyList(), + cellNums: List = emptyList() + ): SurveyMode = + SurveyMode( + revolution = entity.revolution, + time = entity.startTime, + timeStop = entity.endTime, + roll = entity.roll, + latitude = entity.lat, + longitude = entity.longitude, + duration = entity.duration, + contourWKT = entity.contourWkt ?: "", + cellNum = primaryCellNum(cellNums, entity.cellNum), + source = entity.source, + bookedSlotIds = bookedSlotIds, + cellNums = normalizeCellNums(cellNums, entity.cellNum) + ) + + fun toResponse( + entity: SatelliteModeEntity, + bookedSlotIds: List = emptyList(), + cellNums: List = emptyList() + ): SatelliteModeResponseDTO = + SatelliteModeResponseDTO( + id = entity.id ?: 0, + satelliteId = entity.satelliteId, + source = entity.source.name, + type = entity.type.name, + startTime = entity.startTime, + endTime = entity.endTime, + revolution = entity.revolution, + lat = entity.lat, + longitude = entity.longitude, + duration = entity.duration, + contourWkt = entity.contourWkt, + roll = entity.roll, + cellNum = primaryCellNum(cellNums, entity.cellNum), + cellNums = normalizeCellNums(cellNums, entity.cellNum), + bookedSlotIds = bookedSlotIds, + snapshotId = entity.snapshot?.id + ) + + private fun SurveyMode.primaryCellNum(): Long = + cellNumsForPersistence().singleOrNull() ?: -1 + + private fun SurveyMode.cellNumsForPersistence(): List = + normalizeCellNums(cellNums, cellNum) + + private fun normalizeCellNums(cellNums: List, fallbackCellNum: Long): List { + val normalized = cellNums + .ifEmpty { if (fallbackCellNum > 0) listOf(fallbackCellNum) else emptyList() } + .filter { it > 0 } + .distinct() + .sorted() + + return normalized + } + + private fun primaryCellNum(cellNums: List, fallbackCellNum: Long): Long = + normalizeCellNums(cellNums, fallbackCellNum).singleOrNull() ?: -1 + + private fun resolveCoordinates(survey: SurveyMode): Pair { + if (survey.latitude != 0.0 || survey.longitude != 0.0) { + return survey.latitude to survey.longitude + } + if (survey.contourWKT.isBlank()) { + return survey.latitude to survey.longitude + } + + return runCatching { + val centroid = WKTReader().read(survey.contourWKT).centroid + centroid.y to centroid.x + }.getOrDefault(survey.latitude to survey.longitude) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModePersistenceService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModePersistenceService.kt new file mode 100644 index 0000000..6abeb6b --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModePersistenceService.kt @@ -0,0 +1,321 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import java.time.Clock +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@Service +class SatelliteModePersistenceService( + private val satelliteModeRepository: SatelliteModeRepository, + private val satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository, + private val satelliteModeCellRepository: SatelliteModeCellRepository, + private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository, + private val satelliteModeMapper: SatelliteModeMapper, + private val clock: Clock +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun deleteModesForInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ) { + validateInterval(intervalStart, intervalEnd) + + val selectedSatelliteIds = satelliteIds.distinct() + if (selectedSatelliteIds.isEmpty()) { + logger.info("Удаление режимов пропущено: пустой список спутников") + return + } + + var modeIds = emptyList() + var snapshotIds = emptyList() + val lookupMs = measureTimeMillis { + modeIds = satelliteModeRepository.findIntersectingIdsBySatelliteIdsAndInterval( + selectedSatelliteIds, + intervalStart, + intervalEnd + ) + snapshotIds = satelliteModeSnapshotRepository.findIntersectingIdsBySatelliteIdsAndInterval( + selectedSatelliteIds, + intervalStart, + intervalEnd + ) + } + logger.info( + "Satellite mode delete stage lookup completed: satellites={}, snapshots={}, modes={}, durationMs={}", + selectedSatelliteIds, + snapshotIds.size, + modeIds.size, + lookupMs + ) + + if (modeIds.isEmpty() && snapshotIds.isEmpty()) { + logger.info( + "Для удаления не найдено пересекающихся snapshot/result-set: satellites={}, interval=[{} - {}]", + selectedSatelliteIds, + intervalStart, + intervalEnd + ) + return + } + + logger.info( + "Удаление legacy snapshot/result-set: satellites={}, interval=[{} - {}], snapshots={}, modes={}", + selectedSatelliteIds, + intervalStart, + intervalEnd, + snapshotIds.size, + modeIds.size + ) + val deleteMs = measureTimeMillis { + if (modeIds.isNotEmpty()) { + satelliteModeBookedSlotRepository.deleteAllByModeIds(modeIds) + satelliteModeCellRepository.deleteAllByModeIds(modeIds) + satelliteModeRepository.deleteIntersectingBySatelliteIdsAndInterval( + selectedSatelliteIds, + intervalStart, + intervalEnd + ) + } + if (snapshotIds.isNotEmpty()) { + satelliteModeSnapshotRepository.deleteAllByIdInBatch(snapshotIds) + } + } + logger.info("Удаление legacy snapshot/result-set завершено: durationMs={}", deleteMs) + } + + @Transactional + fun saveModesForInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + surveysBySatelliteId: Map>, + complexPlanRun: ComplexPlanRunEntity? = null + ): SatelliteModeSaveResult { + validateInterval(intervalStart, intervalEnd) + + val selectedSatelliteIds = satelliteIds.distinct() + if (selectedSatelliteIds.isEmpty()) { + logger.info("Сохранение режимов пропущено: пустой список спутников") + return SatelliteModeSaveResult() + } + + return saveSnapshotModes( + satelliteIds = selectedSatelliteIds, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = surveysBySatelliteId, + complexPlanRun = complexPlanRun, + activateSnapshot = true + ) + } + + @Transactional + fun replaceModesForInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + surveysBySatelliteId: Map>, + complexPlanRun: ComplexPlanRunEntity? = null + ): SatelliteModeSaveResult { + validateInterval(intervalStart, intervalEnd) + val selectedSatelliteIds = satelliteIds.distinct() + if (selectedSatelliteIds.isEmpty()) { + logger.info("Atomic replace режимов пропущен: пустой список спутников") + return SatelliteModeSaveResult() + } + + return saveSnapshotModes( + satelliteIds = selectedSatelliteIds, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = surveysBySatelliteId, + complexPlanRun = complexPlanRun, + activateSnapshot = true + ) + } + + @Transactional + fun clearAll() { + logger.info("Полная очистка таблиц satellite_mode_snapshots и satellite_modes") + val durationMs = measureTimeMillis { + satelliteModeBookedSlotRepository.deleteAllInBatch() + satelliteModeCellRepository.deleteAllInBatch() + satelliteModeRepository.deleteAllInBatch() + satelliteModeSnapshotRepository.deleteAllInBatch() + } + logger.info("Полная очистка таблиц satellite_mode_snapshots и satellite_modes завершена: durationMs={}", durationMs) + } + + private fun saveSnapshotModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + surveysBySatelliteId: Map>, + complexPlanRun: ComplexPlanRunEntity?, + activateSnapshot: Boolean + ): SatelliteModeSaveResult { + lateinit var snapshots: List + val createSnapshotsMs = measureTimeMillis { + snapshots = satelliteIds.map { satelliteId -> + SatelliteModeSnapshotEntity( + satelliteId = satelliteId, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + createdAt = LocalDateTime.now(clock), + isActive = false, + complexPlanRun = complexPlanRun + ) + } + satelliteModeSnapshotRepository.saveAll(snapshots) + } + logger.info( + "Satellite mode save stage snapshots-created: snapshots={}, durationMs={}", + snapshots.size, + createSnapshotsMs + ) + + lateinit var entities: List + val mapEntitiesMs = measureTimeMillis { + entities = snapshots.flatMap { snapshot -> + surveysBySatelliteId[snapshot.satelliteId] + .orEmpty() + .filter { intersects(it.time, it.timeStop, intervalStart, intervalEnd) } + .map { survey -> + satelliteModeMapper.toEntity( + satelliteId = snapshot.satelliteId, + survey = survey, + snapshot = snapshot, + complexPlanRun = complexPlanRun + ) + } + } + } + logger.info( + "Satellite mode save stage entities-mapped: snapshots={}, modes={}, durationMs={}", + snapshots.size, + entities.size, + mapEntitiesMs + ) + + if (entities.isNotEmpty()) { + logger.info( + "Сохранение stage 4 snapshot: satellites={}, interval=[{} - {}], snapshots={}, modes={}", + satelliteIds, + intervalStart, + intervalEnd, + snapshots.size, + entities.size + ) + val saveModesMs = measureTimeMillis { + satelliteModeRepository.saveAll(entities) + } + logger.info( + "Satellite mode save stage modes-saved: modes={}, durationMs={}", + entities.size, + saveModesMs + ) + } else { + logger.info( + "Сохранение stage 4 snapshot без режимов: satellites={}, interval=[{} - {}], snapshots={}", + satelliteIds, + intervalStart, + intervalEnd, + snapshots.size + ) + } + + val activeSnapshotIds = if (activateSnapshot) { + val activateMs = measureTimeMillis { + snapshots.forEach { snapshot -> + satelliteModeSnapshotRepository.deactivateActiveSnapshots(snapshot.satelliteId) + snapshot.isActive = true + } + } + logger.info( + "Satellite mode save stage snapshots-activated: snapshots={}, durationMs={}", + snapshots.size, + activateMs + ) + snapshots.mapNotNull { it.id } + } else { + emptyList() + } + + logger.info( + "Сохранение stage 4 snapshot завершено: snapshots={}, activeSnapshots={}", + snapshots.mapNotNull { it.id }, + activeSnapshotIds + ) + return SatelliteModeSaveResult.from( + entities = entities, + snapshotIds = snapshots.mapNotNull { it.id }, + activeSnapshotIds = activeSnapshotIds + ) + } + + private fun validateInterval(intervalStart: LocalDateTime, intervalEnd: LocalDateTime) { + if (intervalEnd.isBefore(intervalStart)) { + throw CustomValidationException("Параметр intervalEnd должен быть больше или равен intervalStart") + } + } + + private fun intersects( + start1: LocalDateTime, + end1: LocalDateTime, + start2: LocalDateTime, + end2: LocalDateTime + ): Boolean = start1 < end2 && start2 < end1 +} + +data class SatelliteModeSaveResult( + val calculatedModesCount: Int = 0, + val bookedModesCount: Int = 0, + val complanModesCount: Int = 0, + val mixedModesCount: Int = 0, + val affectedSatellitesCount: Int = 0, + val processedCellsCount: Int = 0, + val bookedSlotLinksCount: Int = 0, + val modeIds: List = emptyList(), + val snapshotIds: List = emptyList(), + val activeSnapshotIds: List = emptyList() +) { + companion object { + fun from( + entities: List, + snapshotIds: List, + activeSnapshotIds: List + ): SatelliteModeSaveResult = + SatelliteModeSaveResult( + calculatedModesCount = entities.size, + bookedModesCount = entities.count { it.source == SatelliteModeSource.SLOTS }, + complanModesCount = entities.count { it.source == SatelliteModeSource.COMPLAN }, + mixedModesCount = entities.count { it.source == SatelliteModeSource.MIXED }, + affectedSatellitesCount = entities.map { it.satelliteId }.distinct().size, + processedCellsCount = entities.flatMap { entity -> entity.cells.map { it.cellNum } } + .filter { it > 0 } + .distinct() + .size, + bookedSlotLinksCount = entities.sumOf { it.bookedSlots.size }, + modeIds = entities.mapNotNull { it.id }, + snapshotIds = snapshotIds, + activeSnapshotIds = activeSnapshotIds + ) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeQueryService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeQueryService.kt new file mode 100644 index 0000000..664bc13 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeQueryService.kt @@ -0,0 +1,511 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.locationtech.jts.io.WKTReader +import org.slf4j.LoggerFactory +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DailyDurationDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionStatisticsDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.RevolutionDurationDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import kotlin.system.measureTimeMillis + +@Service +class SatelliteModeQueryService( + private val satelliteModeRepository: SatelliteModeRepository, + private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository, + private val satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository, + private val satelliteModeCellRepository: SatelliteModeCellRepository, + private val satelliteModeMapper: SatelliteModeMapper, + private val satelliteModeSnapshotMapper: SatelliteModeSnapshotMapper +) { + + private data class ModeKey( + val id: Long?, + val satelliteId: Long, + val startTime: LocalDateTime, + val endTime: LocalDateTime + ) + + private val wktReader = WKTReader() + private val logger = LoggerFactory.getLogger(this::class.java) + + fun findMissionModes(satelliteId: Long): List = + satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId) + .let { modes -> + val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id }) + modes.map { entity -> + satelliteModeMapper.toSurveyMode( + entity = entity, + cellNums = cellNumsByModeId[entity.id].orEmpty() + ).toDTO() + } + } + + fun missionStatistics(satelliteId: Long): MissionStatisticsDTO { + val surveys = satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId) + .let { modes -> + val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id }) + modes.map { entity -> + satelliteModeMapper.toSurveyMode( + entity = entity, + cellNums = cellNumsByModeId[entity.id].orEmpty() + ) + } + } + + return MissionStatisticsDTO( + modesCount = surveys.size, + totalContourArea = surveys.sumOf { survey -> contourArea(survey.contourWKT) }, + revolutionDurations = surveys + .groupBy { it.revolution } + .toSortedMap() + .map { (revolution, items) -> + RevolutionDurationDTO( + revolution = revolution, + totalDuration = items.sumOf { it.duration } + ) + }, + dailyDurations = surveys + .flatMap { survey -> splitSurveyDurationByDay(survey) } + .groupBy({ it.first }, { it.second }) + .toSortedMap() + .map { (date, durations) -> + DailyDurationDTO( + date = date, + totalDuration = durations.sum() + ) + } + ) + } + + fun buildPaintGeometry(satelliteId: Long): String { + val contours = satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId) + .mapNotNull { it.contourWkt?.takeIf(String::isNotBlank) } + + if (contours.isEmpty()) { + return "GEOMETRYCOLLECTION EMPTY" + } + + return contours.joinToString( + prefix = "GEOMETRYCOLLECTION(", + postfix = ")" + ) + } + + fun buildCellPaintGeometry(cellNum: Long): String { + val contours = satelliteModeRepository.findAllByRelatedCellNumOrderByStartTimeAsc(cellNum) + .mapNotNull { it.contourWkt?.takeIf(String::isNotBlank) } + + if (contours.isEmpty()) { + return "GEOMETRYCOLLECTION EMPTY" + } + + return contours.joinToString( + prefix = "GEOMETRYCOLLECTION(", + postfix = ")" + ) + } + + fun findModesByInterval( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + runId: Long? = null + ): List { + validateInterval(intervalStart, intervalEnd) + if (satelliteIds.isEmpty()) { + return emptyList() + } + + val distinctSatelliteIds = satelliteIds.distinct() + val snapshots = if (runId == null) { + satelliteModeSnapshotRepository + .findAllBySatelliteIdInAndIsActiveTrueOrderBySatelliteIdAscCreatedAtDesc(distinctSatelliteIds) + } else { + satelliteModeSnapshotRepository + .findAllByComplexPlanRun_IdAndSatelliteIdInOrderBySatelliteIdAscCreatedAtDesc( + runId, + distinctSatelliteIds + ) + } + val snapshotIds = snapshots.distinctBy { it.satelliteId }.mapNotNull { it.id } + + return findModesBySnapshotIds(snapshotIds) + .filter { mode -> mode.startTime < intervalEnd && intervalStart < mode.endTime } + } + + fun findModesByRun(runId: Long): List { + val modes = satelliteModeRepository.findAllByComplexPlanRun_IdOrderByStartTimeAsc(runId) + val bookedSlotsByModeId = bookedSlotsByModeId(modes.mapNotNull { it.id }) + val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id }) + + return modes.map { mode -> + satelliteModeMapper.toResponse( + entity = mode, + bookedSlotIds = bookedSlotsByModeId[mode.id].orEmpty(), + cellNums = cellNumsByModeId[mode.id].orEmpty() + ) + } + } + + fun findSnapshot(snapshotId: Long): SatelliteModeSnapshotResponseDTO = + satelliteModeSnapshotMapper.toResponse( + satelliteModeSnapshotRepository.findById(snapshotId) + .orElseThrow { CustomValidationException("Satellite mode snapshot $snapshotId not found") } + ) + + fun findSnapshots( + satelliteId: Long?, + intervalStart: LocalDateTime?, + intervalEnd: LocalDateTime?, + activeOnly: Boolean + ): List = + satelliteModeSnapshotRepository.findAll( + buildSnapshotSpecification( + satelliteId = satelliteId, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + activeOnly = activeOnly + ), + Sort.by( + Sort.Order.asc("satelliteId"), + Sort.Order.desc("intervalStart"), + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ) + ).map(satelliteModeSnapshotMapper::toResponse) + + fun findActiveSnapshot( + satelliteId: Long, + intervalStart: LocalDateTime? = null, + intervalEnd: LocalDateTime? = null + ): SatelliteModeSnapshotResponseDTO? { + if ((intervalStart == null) != (intervalEnd == null)) { + throw CustomValidationException("Параметры intervalStart и intervalEnd должны передаваться вместе") + } + if (intervalStart != null && intervalEnd != null) { + validateInterval(intervalStart, intervalEnd) + } + return satelliteModeSnapshotRepository + .findFirstBySatelliteIdAndIsActiveTrueOrderByCreatedAtDescIdDesc(satelliteId) + ?.let(satelliteModeSnapshotMapper::toResponse) + } + + fun findModesBySnapshot( + snapshotId: Long, + intervalStart: LocalDateTime? = null, + intervalEnd: LocalDateTime? = null + ): List { + if ((intervalStart == null) != (intervalEnd == null)) { + throw CustomValidationException("Параметры intervalStart и intervalEnd должны передаваться вместе") + } + if (intervalStart != null && intervalEnd != null) { + validateInterval(intervalStart, intervalEnd) + } + return findModesBySnapshotIds(listOf(snapshotId)) + .filter { mode -> + if (intervalStart == null || intervalEnd == null) { + true + } else { + mode.startTime < intervalEnd && intervalStart < mode.endTime + } + } + } + + fun loadWorkingModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> = + loadConstraintModes(satelliteIds, intervalStart, intervalEnd) + + fun loadConstraintModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> { + validateInterval(intervalStart, intervalEnd) + if (satelliteIds.isEmpty()) { + return emptyMap() + } + + val distinctSatelliteIds = satelliteIds.distinct() + lateinit var dayModes: List + val dayModesMs = measureTimeMillis { + dayModes = loadDayConstraintModeEntities(distinctSatelliteIds, intervalStart, intervalEnd) + } + logger.info( + "Satellite mode query stage day-constraints loaded: satellites={}, modes={}, durationMs={}", + distinctSatelliteIds, + dayModes.size, + dayModesMs + ) + + lateinit var revolutionModes: List + val revolutionModesMs = measureTimeMillis { + revolutionModes = loadRevolutionConstraintModeEntities(dayModes) + } + logger.info( + "Satellite mode query stage revolution-constraints loaded: satellites={}, modes={}, durationMs={}", + distinctSatelliteIds, + revolutionModes.size, + revolutionModesMs + ) + + val modes = (dayModes + revolutionModes) + .distinctBy { entity -> ModeKey(entity.id, entity.satelliteId, entity.startTime, entity.endTime) } + .sortedBy { it.startTime } + + lateinit var bookedSlotsByModeId: Map> + lateinit var cellNumsByModeId: Map> + val linksMs = measureTimeMillis { + val modeIds = modes.mapNotNull { it.id } + bookedSlotsByModeId = bookedSlotsByModeId(modeIds) + cellNumsByModeId = cellNumsByModeId(modeIds) + } + logger.info( + "Satellite mode query stage mode-links loaded: modes={}, bookedSlotLinks={}, cellLinks={}, durationMs={}", + modes.size, + bookedSlotsByModeId.values.sumOf { it.size }, + cellNumsByModeId.values.sumOf { it.size }, + linksMs + ) + + lateinit var result: Map> + val mapMs = measureTimeMillis { + result = modes + .groupBy { it.satelliteId } + .mapValues { (_, entities) -> + entities.map { entity -> + satelliteModeMapper.toSurveyMode( + entity = entity, + bookedSlotIds = bookedSlotsByModeId[entity.id].orEmpty(), + cellNums = cellNumsByModeId[entity.id].orEmpty() + ) + }.toMutableList() + } + } + logger.info( + "Satellite mode query stage constraints mapped: satellites={}, modes={}, durationMs={}", + result.keys, + result.values.sumOf { it.size }, + mapMs + ) + return result + } + + fun loadDayConstraintModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> { + lateinit var result: Map> + val durationMs = measureTimeMillis { + result = mapConstraintEntitiesToSurveys(loadDayConstraintModeEntities(satelliteIds, intervalStart, intervalEnd)) + } + logger.info( + "Satellite mode query day-constraint modes loaded: satellites={}, modes={}, durationMs={}", + satelliteIds.distinct(), + result.values.sumOf { it.size }, + durationMs + ) + return result + } + + fun loadRevolutionConstraintModes( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): Map> { + lateinit var result: Map> + val durationMs = measureTimeMillis { + val dayModes = loadDayConstraintModeEntities(satelliteIds, intervalStart, intervalEnd) + result = mapConstraintEntitiesToSurveys(loadRevolutionConstraintModeEntities(dayModes)) + } + logger.info( + "Satellite mode query revolution-constraint modes loaded: satellites={}, modes={}, durationMs={}", + satelliteIds.distinct(), + result.values.sumOf { it.size }, + durationMs + ) + return result + } + + private fun loadDayConstraintModeEntities( + satelliteIds: Collection, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): List { + validateInterval(intervalStart, intervalEnd) + if (satelliteIds.isEmpty()) { + return emptyList() + } + + val dayStart = intervalStart.toLocalDate().atStartOfDay() + val lastTouchedDate = touchedDateRange(intervalStart, intervalEnd).last() + val dayEndExclusive = lastTouchedDate.plusDays(1).atStartOfDay() + return satelliteModeRepository + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + satelliteIds.distinct(), + dayStart, + dayEndExclusive + ) + } + + private fun loadRevolutionConstraintModeEntities(dayModes: List): List = + dayModes + .groupBy { it.satelliteId } + .flatMap { (satelliteId, entities) -> + val revolutions = entities.map { it.revolution }.toSet() + if (revolutions.isEmpty()) { + emptyList() + } else { + satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc( + satelliteId, + revolutions + ) + } + } + + private fun mapConstraintEntitiesToSurveys(modes: List): Map> { + if (modes.isEmpty()) { + return emptyMap() + } + + val uniqueModes = modes + .distinctBy { entity -> ModeKey(entity.id, entity.satelliteId, entity.startTime, entity.endTime) } + .sortedBy { it.startTime } + val bookedSlotsByModeId = bookedSlotsByModeId(uniqueModes.mapNotNull { it.id }) + val cellNumsByModeId = cellNumsByModeId(uniqueModes.mapNotNull { it.id }) + + return uniqueModes + .groupBy { it.satelliteId } + .mapValues { (_, entities) -> + entities.map { entity -> + satelliteModeMapper.toSurveyMode( + entity = entity, + bookedSlotIds = bookedSlotsByModeId[entity.id].orEmpty(), + cellNums = cellNumsByModeId[entity.id].orEmpty() + ) + }.toMutableList() + } + } + + private fun findModesBySnapshotIds(snapshotIds: Collection): List { + if (snapshotIds.isEmpty()) { + return emptyList() + } + + val modes = satelliteModeRepository.findAllBySnapshot_IdInOrderByStartTimeAsc(snapshotIds.distinct()) + val bookedSlotsByModeId = bookedSlotsByModeId(modes.mapNotNull { it.id }) + val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id }) + return modes.map { mode -> + satelliteModeMapper.toResponse( + entity = mode, + bookedSlotIds = bookedSlotsByModeId[mode.id].orEmpty(), + cellNums = cellNumsByModeId[mode.id].orEmpty() + ) + } + } + + private fun bookedSlotsByModeId(modeIds: Collection): Map> { + if (modeIds.isEmpty()) { + return emptyMap() + } + + return satelliteModeBookedSlotRepository.findAllByMode_IdIn(modeIds) + .groupBy( + keySelector = { it.mode?.id ?: -1L }, + valueTransform = { it.bookedSlotId } + ) + .mapValues { (_, bookedSlotIds) -> + bookedSlotIds.filter { it > 0 }.distinct() + } + } + + private fun cellNumsByModeId(modeIds: Collection): Map> { + if (modeIds.isEmpty()) { + return emptyMap() + } + + return satelliteModeCellRepository.findAllByMode_IdIn(modeIds) + .groupBy( + keySelector = { it.mode?.id ?: -1L }, + valueTransform = { it.cellNum } + ) + .mapValues { (_, cellNums) -> + cellNums.filter { it > 0 }.distinct().sorted() + } + } + + private fun buildSnapshotSpecification( + satelliteId: Long?, + intervalStart: LocalDateTime?, + intervalEnd: LocalDateTime?, + activeOnly: Boolean + ): Specification = + Specification { root, _, criteriaBuilder -> + val predicates = mutableListOf() + satelliteId?.let { predicates += criteriaBuilder.equal(root.get("satelliteId"), it) } + intervalStart?.let { predicates += criteriaBuilder.equal(root.get("intervalStart"), it) } + intervalEnd?.let { predicates += criteriaBuilder.equal(root.get("intervalEnd"), it) } + if (activeOnly) { + predicates += criteriaBuilder.isTrue(root.get("isActive")) + } + criteriaBuilder.and(*predicates.toTypedArray()) + } + + private fun validateInterval(intervalStart: LocalDateTime, intervalEnd: LocalDateTime) { + if (intervalEnd.isBefore(intervalStart)) { + throw CustomValidationException("Параметр intervalEnd должен быть больше или равен intervalStart") + } + } + + private fun touchedDateRange(intervalStart: LocalDateTime, intervalEnd: LocalDateTime): List { + val lastTouchedDate = when { + intervalEnd.isEqual(intervalStart) -> intervalStart.toLocalDate() + intervalEnd.toLocalTime() == LocalTime.MIDNIGHT -> intervalEnd.minusNanos(1).toLocalDate() + else -> intervalEnd.minusNanos(1).toLocalDate() + } + return generateSequence(intervalStart.toLocalDate()) { current -> + current.takeIf { it < lastTouchedDate }?.plusDays(1) + }.toList() + } + + private fun contourArea(contourWkt: String): Double = + if (contourWkt.isBlank()) 0.0 + else runCatching { wktReader.read(contourWkt).area }.getOrDefault(0.0) + + private fun splitSurveyDurationByDay(survey: SurveyMode): List> { + if (!survey.timeStop.isAfter(survey.time)) { + return listOf(survey.time.toLocalDate() to survey.duration) + } + + val durations = mutableListOf>() + var current = survey.time + + while (current.toLocalDate() < survey.timeStop.toLocalDate()) { + val nextDayStart = current.toLocalDate().plusDays(1).atStartOfDay() + durations += current.toLocalDate() to Duration.between(current, nextDayStart).toSeconds().toDouble() + current = nextDayStart + } + + durations += current.toLocalDate() to Duration.between(current, survey.timeStop).toSeconds().toDouble() + return durations + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeSnapshotMapper.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeSnapshotMapper.kt new file mode 100644 index 0000000..38f4e3f --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeSnapshotMapper.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO + +@Component +class SatelliteModeSnapshotMapper { + + fun toResponse(entity: SatelliteModeSnapshotEntity): SatelliteModeSnapshotResponseDTO = + SatelliteModeSnapshotResponseDTO( + id = entity.id ?: 0, + satelliteId = entity.satelliteId, + intervalStart = entity.intervalStart, + intervalEnd = entity.intervalEnd, + createdAt = entity.createdAt, + isActive = entity.isActive, + complexPlanRunId = entity.complexPlanRun?.id + ) +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteService.kt new file mode 100644 index 0000000..805d815 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteService.kt @@ -0,0 +1,184 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.locationtech.jts.io.WKTReader +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_satellites_service.model.BLS +import space.nstart.pcp.pcp_satellites_service.model.LongTermMission +import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DailyDurationDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionStatisticsDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.RevolutionDurationDTO +import java.time.Duration +import java.time.LocalDateTime +import kotlin.system.measureTimeMillis + +@Service +class SatelliteService( + private val satelliteCatalogClient: SatelliteCatalogClient +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val wktReader = WKTReader() + private val satellitesById = linkedMapOf() + + val satellites: List + get() = ensureLoaded().values.toList() + + fun clear() { + satellites.forEach { satellite -> satellite.longMission.surveys.clear() } + } + + fun all() = satellites.map { sat -> sat.toDTO() } + + fun byId(id: Long) = ensureLoaded()[id]?.toDTO() + + fun complexMission(id: Long) = + ensureLoaded()[id] + ?.longMission?.surveys?.sortedBy { survey -> survey.time }?.map { survey -> survey.toDTO() } + ?: throw CustomErrorException("Нет КА с заданным идентификаторм $id") + + fun missionStatistics(id: Long): MissionStatisticsDTO { + val mission = ensureLoaded()[id] + ?.longMission + ?: throw CustomErrorException("Нет КА с заданным идентификаторм $id") + + return MissionStatisticsDTO( + modesCount = mission.surveys.size, + totalContourArea = mission.surveys.sumOf { survey -> contourArea(survey.contourWKT) }, + revolutionDurations = mission.surveys + .groupBy { it.revolution } + .toSortedMap() + .map { (revolution, surveys) -> + RevolutionDurationDTO( + revolution = revolution, + totalDuration = surveys.sumOf { survey -> survey.duration } + ) + }, + dailyDurations = mission.surveys + .flatMap { survey -> splitSurveyDurationByDay(survey) } + .groupBy({ it.first }, { it.second }) + .toSortedMap() + .map { (date, durations) -> + DailyDurationDTO( + date = date, + totalDuration = durations.sum() + ) + } + ) + } + + fun complexMissionForPaint(id: Long): String { + val plan = ensureLoaded()[id] + ?.longMission?.surveys + ?: throw CustomErrorException("Нет КА с заданным идентификаторм $id") + + var geom = "GEOMETRYCOLLECTION(" + plan.forEach { survey -> + geom += "${survey.contourWKT}," + } + geom = geom.dropLast(1) + geom += ")" + + return geom + } + + fun hasResources( + tStart: LocalDateTime, + tStop: LocalDateTime, + available: List, + sats: MutableList + ): Boolean { + sats.clear() + var currentDay = tStart.toLocalDate() + val lastDay = tStop.toLocalDate().minusDays(1) + var hasResource = false + + while (currentDay <= lastDay) { + satellites.forEach { satellite -> + if (available.contains(satellite.satelliteId) && satellite.hasResources(currentDay)) { + hasResource = true + if (!sats.contains(satellite.satelliteId)) { + sats.add(satellite.satelliteId) + } + } + } + currentDay = currentDay.plusDays(1) + } + return hasResource + } + + private fun ensureLoaded(): Map { + if (satellitesById.isNotEmpty()) { + logger.info("Satellite cache hit: satellites={}", satellitesById.size) + return satellitesById + } + + synchronized(satellitesById) { + if (satellitesById.isEmpty()) { + val durationMs = measureTimeMillis { + satelliteCatalogClient.allSatellites() + .sortedBy { it.id } + .forEach { satellite -> + satellitesById[satellite.id] = satellite.toSatelliteModel() + } + } + logger.info( + "Satellite cache loaded: satellites={}, durationMs={}", + satellitesById.size, + durationMs + ) + } + } + return satellitesById + } + + private fun SatelliteDTO.toSatelliteModel() = SatelliteModel( + satelliteId = id, + name = name, + red = visualization.red, + green = visualization.green, + blue = visualization.blue, + longMission = LongTermMission(), + bls = observationProfile?.let { + BLS( + captureAngle = it.captureAngle, + sunAngleMin = it.sunAngleMin, + durationMin = it.durationMinSeconds.toDouble(), + durationMax = it.durationMaxSeconds.toDouble(), + mmi = it.mmiSeconds.toDouble(), + dailyMaxDuration = it.dailyMaxDurationSeconds.toDouble(), + revolutionMaxDuration = it.revolutionMaxDurationSeconds.toDouble() + ) + } ?: BLS(), + scanTLE = scanTle + ) + + private fun contourArea(contourWkt: String): Double = + if (contourWkt.isBlank()) { + 0.0 + } else { + runCatching { wktReader.read(contourWkt).area }.getOrDefault(0.0) + } + + private fun splitSurveyDurationByDay(survey: SurveyMode): List> { + if (!survey.timeStop.isAfter(survey.time)) { + return listOf(survey.time.toLocalDate() to survey.duration) + } + + val durations = mutableListOf>() + var current = survey.time + + while (current.toLocalDate() < survey.timeStop.toLocalDate()) { + val nextDayStart = current.toLocalDate().plusDays(1).atStartOfDay() + durations += current.toLocalDate() to Duration.between(current, nextDayStart).toSeconds().toDouble() + current = nextDayStart + } + + durations += current.toLocalDate() to Duration.between(current, survey.timeStop).toSeconds().toDouble() + return durations + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SlotService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SlotService.kt new file mode 100644 index 0000000..1b04b76 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SlotService.kt @@ -0,0 +1,122 @@ +package space.nstart.pcp.pcp_satellites_service.service + +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 org.springframework.web.reactive.function.client.bodyToFlux +import org.springframework.web.util.UriComponentsBuilder +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime + +@Service +class SlotService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.slots-service:slots-service}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + fun slots(wkt : String, tn : LocalDateTime, tk : LocalDateTime, sign : RevolutionSign?, cov : Boolean?, sats : List) = + ( + if (sign == null) { + client() + .get() + .uri( + UriComponentsBuilder + .fromUriString("/api/slots/poly-cover") + .queryParam("wkt", wkt) + .queryParam("timeStart", tn) + .queryParam("timeStop", tk) + .queryParam("cov", cov) + .apply { + sats.forEach { queryParam("satellites", it) } + } + .build() + .toUriString() + ) + } else { + client() + .get() + .uri( + "/api/slots/poly-cover?wkt={wkt}&timeStart={timeStart}&timeStop={timeStop}&revSign={revSign}&cov={cov}", + wkt, + tn, + tk, + sign, + cov + ) + } + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux() + + + fun plan(id : Long, tn : LocalDateTime, tk : LocalDateTime) = + client() + .get() + .uri( + "/api/slots/mission/satellite?satellite_id={satelliteId}&timeStart={timeStart}&timeStop={timeStop}", + id, + tn, + tk + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux< SurveySlotDTO>() + + + fun bookReq(req: BookingRequestDTO): List = + client() + .post() + .uri("/api/slots/booking/request") + .bodyValue(req) + .retrieve() + .bodyToFlux(BookedSlotDTO::class.java) + .collectList() + .block() ?: emptyList() + + fun cancelReq(requestId: String): Int = + client() + .delete() + .uri("/api/slots/booking/request?requestId={requestId}", requestId) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(Int::class.java) + .block() ?: 0 + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} 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 new file mode 100644 index 0000000..b58ce9f --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt @@ -0,0 +1,564 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.io.WKTWriter +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.BLS +import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import kotlin.math.abs +import kotlin.math.ceil + +@Service +class SurveyPlacementService { + + data class PlacementDecision( + val accepted: Boolean, + val appliedSurvey: SurveyMode? = null, + val adjustedCandidate: SurveyMode? = null, + val absorbedSurveys: List = emptyList(), + val trimmed: Boolean = false, + val rejectionReason: String? = null + ) + + private data class PlacementCandidate( + val adjustedCandidate: SurveyMode, + val absorbedSurveys: List, + val mergedSurvey: SurveyMode, + val resultingSurveys: List + ) + + private data class PlacementValidation( + val accepted: Boolean, + val reason: String? = null + ) + + private data class GeometryMergeAssessment( + val mergeable: Boolean, + val intersectionArea: Double? = null, + val touchesOrIntersects: Boolean = false, + val distance: Double? = null, + val geometryChecked: Boolean = true + ) + + companion object { + private const val ROLL_EPSILON = 0.01 + private const val GEOMETRY_INTERSECTION_AREA_EPSILON = 1e-8 + private const val GEOMETRY_DISTANCE_EPSILON = 1e-6 + private const val MERGE_MAX_TIME_GAP_SECONDS = 1L + } + + private val logger: Logger = LoggerFactory.getLogger(this::class.java) + + fun tryPlaceSurvey( + existingSurveys: MutableList, + candidateSurvey: SurveyMode, + satellite: SatelliteModel + ): PlacementDecision { + val constraints = satellite.bls + val initialPlacement = buildPlacementCandidate(existingSurveys, candidateSurvey, satellite.satelliteId) + val initialValidation = validatePlacement(initialPlacement, constraints) + if (initialValidation.accepted) { + applyPlacement(existingSurveys, initialPlacement) + return PlacementDecision( + accepted = true, + appliedSurvey = initialPlacement.mergedSurvey, + adjustedCandidate = initialPlacement.adjustedCandidate, + absorbedSurveys = initialPlacement.absorbedSurveys + ) + } + + val adjustedPlacement = adjustPlacementToConstraints(existingSurveys, candidateSurvey, constraints, satellite.satelliteId) + ?: return PlacementDecision( + accepted = false, + rejectionReason = initialValidation.reason ?: "placement_rejected" + ) + val adjustedValidation = validatePlacement(adjustedPlacement, constraints) + if (!adjustedValidation.accepted) { + return PlacementDecision( + accepted = false, + rejectionReason = adjustedValidation.reason ?: "placement_rejected" + ) + } + + applyPlacement(existingSurveys, adjustedPlacement) + return PlacementDecision( + accepted = true, + appliedSurvey = adjustedPlacement.mergedSurvey, + adjustedCandidate = adjustedPlacement.adjustedCandidate, + absorbedSurveys = adjustedPlacement.absorbedSurveys, + trimmed = adjustedPlacement.adjustedCandidate.timeStop != candidateSurvey.timeStop + ) + } + + fun mergeSurvey( + existingSurveys: MutableList, + candidateSurvey: SurveyMode, + satellite: SatelliteModel + ): SurveyMode { + val placement = buildPlacementCandidate(existingSurveys, candidateSurvey, satellite.satelliteId) + applyPlacement(existingSurveys, placement) + return placement.mergedSurvey + } + + private fun buildPlacementCandidate( + existingSurveys: List, + candidateSurvey: SurveyMode, + satelliteId: Long + ): PlacementCandidate { + val mergeChain = buildMergeChain(existingSurveys, candidateSurvey, satelliteId) + val resultingSurveys = existingSurveys + .filterNot { mergeChain.absorbedSurveys.contains(it) } + .plus(mergeChain.mergedSurvey) + .sortedBy { it.time } + + return PlacementCandidate( + adjustedCandidate = candidateSurvey, + absorbedSurveys = mergeChain.absorbedSurveys, + mergedSurvey = mergeChain.mergedSurvey, + resultingSurveys = resultingSurveys + ) + } + + private fun adjustPlacementToConstraints( + existingSurveys: List, + candidateSurvey: SurveyMode, + constraints: BLS, + satelliteId: Long + ): PlacementCandidate? { + val originalDurationSeconds = Duration.between(candidateSurvey.time, candidateSurvey.timeStop).seconds + if (originalDurationSeconds <= 0) { + return null + } + + val minDurationSeconds = ceil(constraints.durationMin).toLong().coerceAtLeast(1L) + var low = minDurationSeconds + var high = originalDurationSeconds + var bestPlacement: PlacementCandidate? = null + + while (low <= high) { + val mid = (low + high) / 2 + val adjustedCandidate = trimCandidate(candidateSurvey, mid) + val placement = buildPlacementCandidate(existingSurveys, adjustedCandidate, satelliteId) + val validation = validatePlacement(placement, constraints) + if (validation.accepted) { + bestPlacement = placement + low = mid + 1 + } else { + high = mid - 1 + } + } + + return bestPlacement + } + + private fun validatePlacement( + placement: PlacementCandidate, + constraints: BLS + ): PlacementValidation { + val candidateDurationSeconds = Duration.between( + placement.adjustedCandidate.time, + placement.adjustedCandidate.timeStop + ).seconds.toDouble() + if (candidateDurationSeconds < constraints.durationMin) { + return PlacementValidation(false, "duration_min") + } + + if (placement.mergedSurvey.duration > constraints.durationMax) { + return PlacementValidation(false, "duration_max") + } + + if (hasDifferentRollConflict(placement, constraints)) { + return PlacementValidation(false, "different_roll_conflict") + } + + if (exceedsDailyMaxDuration(placement.resultingSurveys, constraints)) { + return PlacementValidation(false, "daily_max_duration") + } + + if (exceedsRevolutionMaxDuration(placement.resultingSurveys, constraints)) { + return PlacementValidation(false, "revolution_max_duration") + } + + return PlacementValidation(true) + } + + private fun applyPlacement( + existingSurveys: MutableList, + placement: PlacementCandidate + ) { + existingSurveys.removeAll(placement.absorbedSurveys.toSet()) + existingSurveys.add(placement.mergedSurvey) + existingSurveys.sortBy { it.time } + } + + private data class MergeChain( + val absorbedSurveys: List, + val mergedSurvey: SurveyMode + ) + + private fun buildMergeChain( + existingSurveys: List, + candidateSurvey: SurveyMode, + satelliteId: Long + ): MergeChain { + val absorbed = linkedSetOf() + var mergedSurvey = candidateSurvey + var expanded = true + + while (expanded) { + expanded = false + existingSurveys.forEach { existing -> + if (absorbed.contains(existing)) { + return@forEach + } + if (!sameRoll(existing.roll, candidateSurvey.roll)) { + return@forEach + } + if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) { + return@forEach + } + val overlapsInTime = intersectsInTime( + existing.time, + existing.timeStop, + mergedSurvey.time, + mergedSurvey.timeStop + ) + val geometryMergeAssessment = assessMergeableGeometry( + leftContour = existing.contourWKT, + rightContour = mergedSurvey.contourWKT, + satelliteId = satelliteId, + allowContinuityWithoutArea = !overlapsInTime + ) + if (!geometryMergeAssessment.mergeable) { + if (geometryMergeAssessment.geometryChecked) { + logMergeSkippedWithoutSufficientGeometry( + satelliteId = satelliteId, + candidateSurvey = mergedSurvey, + existingSurvey = existing, + intersectionArea = geometryMergeAssessment.intersectionArea ?: 0.0, + distance = geometryMergeAssessment.distance, + overlapsInTime = overlapsInTime + ) + } + return@forEach + } + val mergedContour = unionContoursIfPolygon( + baseContour = mergedSurvey.contourWKT, + contourToAdd = existing.contourWKT, + satelliteId = satelliteId, + candidateSurvey = mergedSurvey, + existingSurvey = existing + ) ?: return@forEach + absorbed += existing + mergedSurvey = mergeSurveys(mergedSurvey, existing, mergedContour) + expanded = true + } + } + + return MergeChain(absorbed.toList(), mergedSurvey) + } + + private fun mergeSurveys( + baseSurvey: SurveyMode, + existingSurvey: SurveyMode, + mergedContour: String + ): SurveyMode { + val mergedStart = minOf(baseSurvey.time, existingSurvey.time) + val mergedStop = maxOf(baseSurvey.timeStop, existingSurvey.timeStop) + val mergedRevolution = if (existingSurvey.time < baseSurvey.time) { + existingSurvey.revolution + } else { + baseSurvey.revolution + } + val mergedBookedSlotIds = (baseSurvey.bookedSlotIds + existingSurvey.bookedSlotIds).distinct() + val mergedCellNums = normalizeCellNums(baseSurvey).apply { + addAll(normalizeCellNums(existingSurvey)) + } + return SurveyMode( + revolution = mergedRevolution, + time = mergedStart, + timeStop = mergedStop, + roll = baseSurvey.roll, + latitude = baseSurvey.latitude, + longitude = baseSurvey.longitude, + duration = Duration.between(mergedStart, mergedStop).seconds.toDouble(), + contourWKT = mergedContour, + cellNum = mergedCellNums.singleOrNull() ?: -1, + source = mergeSources(baseSurvey.source, existingSurvey.source), + bookedSlotIds = mergedBookedSlotIds, + cellNums = mergedCellNums.toList().sorted() + ) + } + + private fun hasDifferentRollConflict( + placement: PlacementCandidate, + constraints: BLS + ): Boolean = + placement.resultingSurveys.any { existing -> + existing !== placement.mergedSurvey && + !sameRoll(existing.roll, placement.mergedSurvey.roll) && + intersectsWithMmi( + existing.time, + existing.timeStop, + placement.mergedSurvey.time, + placement.mergedSurvey.timeStop, + constraints.mmi + ) + } + + private fun exceedsDailyMaxDuration( + surveys: List, + constraints: BLS + ): Boolean = + surveys + .flatMap { splitSurveyDurationByDay(it) } + .groupBy({ it.first }, { it.second }) + .values + .any { durations -> durations.sum() > constraints.dailyMaxDuration } + + private fun exceedsRevolutionMaxDuration( + surveys: List, + constraints: BLS + ): Boolean = + surveys + .groupBy { it.revolution } + .values + .any { grouped -> grouped.sumOf { it.duration } > constraints.revolutionMaxDuration } + + private fun splitSurveyDurationByDay(survey: SurveyMode): List> { + if (!survey.timeStop.isAfter(survey.time)) { + return listOf(survey.time.toLocalDate() to survey.duration) + } + + val durations = mutableListOf>() + var current = survey.time + + while (current.toLocalDate() < survey.timeStop.toLocalDate()) { + val nextDayStart = current.toLocalDate().plusDays(1).atStartOfDay() + durations += current.toLocalDate() to Duration.between(current, nextDayStart).seconds.toDouble() + current = nextDayStart + } + + durations += current.toLocalDate() to Duration.between(current, survey.timeStop).seconds.toDouble() + return durations + } + + private fun trimCandidate(candidateSurvey: SurveyMode, allowedDurationSeconds: Long): SurveyMode { + val trimmedEnd = candidateSurvey.time.plusSeconds(allowedDurationSeconds.coerceAtLeast(0)) + return candidateSurvey.copyWith( + timeStop = trimmedEnd, + duration = Duration.between(candidateSurvey.time, trimmedEnd).seconds.toDouble() + ) + } + + private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON + + private fun canMergeInTime( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime + ): Boolean { + val firstStop = minOf(stop1, stop2) + val secondStart = maxOf(start1, start2) + if (!secondStart.isAfter(firstStop)) { + return true + } + val gapSeconds = Duration.between(firstStop, secondStart).seconds + return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS + } + + private fun intersectsInTime( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime + ): Boolean = start1 < stop2 && start2 < stop1 + + private fun intersectsWithMmi( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime, + mmiSeconds: Double + ): Boolean { + val mmi = ceil(mmiSeconds).toLong() + return start1 <= stop2.plusSeconds(mmi) && start2 <= stop1.plusSeconds(mmi) + } + + private fun mergeSources(left: SatelliteModeSource, right: SatelliteModeSource): SatelliteModeSource = when { + left == right -> left + left == SatelliteModeSource.MIXED || right == SatelliteModeSource.MIXED -> SatelliteModeSource.MIXED + else -> SatelliteModeSource.MIXED + } + + private fun normalizeCellNums(survey: SurveyMode): MutableSet = + survey.cellNums + .ifEmpty { if (survey.cellNum > 0) listOf(survey.cellNum) else emptyList() } + .filter { it > 0 } + .toMutableSet() + + private fun assessMergeableGeometry( + leftContour: String, + rightContour: String, + satelliteId: Long, + allowContinuityWithoutArea: Boolean + ): GeometryMergeAssessment { + return try { + val reader = WKTReader() + val leftGeometry = reader.read(leftContour) + val rightGeometry = reader.read(rightContour) + logInvalidGeometryIfNeeded(satelliteId, "left", leftGeometry) + logInvalidGeometryIfNeeded(satelliteId, "right", rightGeometry) + val intersectionArea = leftGeometry.intersection(rightGeometry).area + val touchesOrIntersects = leftGeometry.intersects(rightGeometry) || leftGeometry.touches(rightGeometry) + val distance = if (touchesOrIntersects) 0.0 else leftGeometry.distance(rightGeometry) + GeometryMergeAssessment( + mergeable = intersectionArea > GEOMETRY_INTERSECTION_AREA_EPSILON || + (allowContinuityWithoutArea && (touchesOrIntersects || distance <= GEOMETRY_DISTANCE_EPSILON)), + intersectionArea = intersectionArea, + touchesOrIntersects = touchesOrIntersects, + distance = distance + ) + } catch (e: Exception) { + logger.warn("Не удалось проверить непрерывность геометрий маршрутов при placement: {}", e.message) + GeometryMergeAssessment(mergeable = false, geometryChecked = false) + } + } + + private fun unionContoursIfPolygon( + baseContour: String, + contourToAdd: String, + satelliteId: Long, + candidateSurvey: SurveyMode, + existingSurvey: SurveyMode + ): String? { + return try { + val reader = WKTReader() + val unionedGeometry = reader.read(baseContour).union(reader.read(contourToAdd)) + if (unionedGeometry !is Polygon) { + logger.warn( + "Merge skipped because union produced non-polygon geometry: satelliteId={}, geometryType={}, candidateStart={}, candidateEnd={}, existingStart={}, existingEnd={}, candidateRoll={}, existingRoll={}, candidateSource={}, existingSource={}", + satelliteId, + unionedGeometry.geometryType, + candidateSurvey.time, + candidateSurvey.timeStop, + existingSurvey.time, + existingSurvey.timeStop, + candidateSurvey.roll, + existingSurvey.roll, + candidateSurvey.source, + existingSurvey.source + ) + return null + } + WKTWriter().write(unionedGeometry) + } catch (e: Exception) { + logger.warn("Не удалось объединить контуры маршрутов при placement: {}", e.message) + null + } + } + + private fun logInvalidGeometryIfNeeded( + satelliteId: Long, + geometryRole: String, + geometry: Geometry + ) { + if (geometry.isValid) { + return + } + logger.warn( + "Merge geometry is invalid during placement: satelliteId={}, geometryRole={}, geometryType={}", + satelliteId, + geometryRole, + geometry.geometryType + ) + } + + private fun logMergeSkippedWithoutSufficientGeometry( + satelliteId: Long, + candidateSurvey: SurveyMode, + existingSurvey: SurveyMode, + intersectionArea: Double, + distance: Double?, + overlapsInTime: Boolean + ) { + if (overlapsInTime) { + logger.warn( + "Merge skipped because contours lack sufficient areal intersection despite temporal overlap: satelliteId={}, intersectionArea={}, epsilon={}, candidateStart={}, candidateEnd={}, existingStart={}, existingEnd={}, candidateRoll={}, existingRoll={}, candidateSource={}, existingSource={}, candidateCellNums={}, existingCellNums={}, candidateBookedSlotIds={}, existingBookedSlotIds={}", + satelliteId, + intersectionArea, + GEOMETRY_INTERSECTION_AREA_EPSILON, + candidateSurvey.time, + candidateSurvey.timeStop, + existingSurvey.time, + existingSurvey.timeStop, + candidateSurvey.roll, + existingSurvey.roll, + candidateSurvey.source, + existingSurvey.source, + normalizeCellNums(candidateSurvey).toList().sorted(), + normalizeCellNums(existingSurvey).toList().sorted(), + candidateSurvey.bookedSlotIds, + existingSurvey.bookedSlotIds + ) + return + } + + logger.warn( + "Merge skipped because contours are not continuous enough despite mergeable time window: satelliteId={}, intersectionArea={}, areaEpsilon={}, distance={}, distanceEpsilon={}, maxTimeGapSeconds={}, candidateStart={}, candidateEnd={}, existingStart={}, existingEnd={}, candidateRoll={}, existingRoll={}, candidateSource={}, existingSource={}, candidateCellNums={}, existingCellNums={}, candidateBookedSlotIds={}, existingBookedSlotIds={}", + satelliteId, + intersectionArea, + GEOMETRY_INTERSECTION_AREA_EPSILON, + distance, + GEOMETRY_DISTANCE_EPSILON, + MERGE_MAX_TIME_GAP_SECONDS, + candidateSurvey.time, + candidateSurvey.timeStop, + existingSurvey.time, + existingSurvey.timeStop, + candidateSurvey.roll, + existingSurvey.roll, + candidateSurvey.source, + existingSurvey.source, + normalizeCellNums(candidateSurvey).toList().sorted(), + normalizeCellNums(existingSurvey).toList().sorted(), + candidateSurvey.bookedSlotIds, + existingSurvey.bookedSlotIds + ) + } + + private fun SurveyMode.copyWith( + time: LocalDateTime = this.time, + timeStop: LocalDateTime = this.timeStop, + duration: Double = this.duration, + revolution: Long = this.revolution, + contourWKT: String = this.contourWKT, + cellNum: Long = this.cellNum, + source: SatelliteModeSource = this.source, + bookedSlotIds: List = this.bookedSlotIds, + cellNums: List = this.cellNums + ) = SurveyMode( + revolution = revolution, + time = time, + timeStop = timeStop, + roll = roll, + latitude = latitude, + longitude = longitude, + duration = duration, + contourWKT = contourWKT, + cellNum = cellNum, + source = source, + bookedSlotIds = bookedSlotIds, + cellNums = cellNums + ) +} diff --git a/services/pcp-complex-mission-service/src/main/resources/application.yaml b/services/pcp-complex-mission-service/src/main/resources/application.yaml new file mode 100644 index 0000000..20b63ff --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/application.yaml @@ -0,0 +1,66 @@ +spring: + application: + name: pcp-complex-mission-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} + +--- +# Fallback for direct bootRun when the external config server does not provide service datasource settings. +spring: + config: + activate: + on-profile: local + datasource: + driver-class-name: org.postgresql.Driver + url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_satellites} + username: ${PCP_COMPLEX_MISSION_DATASOURCE_USERNAME:postgres} + password: ${PCP_COMPLEX_MISSION_DATASOURCE_PASSWORD:password} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +--- +spring: + config: + activate: + on-profile: dev + datasource: + driver-class-name: org.postgresql.Driver + url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://192.168.100.160:35400/pcp_satellites} + username: ${PCP_COMPLEX_MISSION_DATASOURCE_USERNAME:postgres} + password: ${PCP_COMPLEX_MISSION_DATASOURCE_PASSWORD:password} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V1__satellite_modes.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V1__satellite_modes.sql new file mode 100644 index 0000000..79c86e1 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V1__satellite_modes.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS satellite_modes ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + satellite_id BIGINT NOT NULL, + source VARCHAR(20) NOT NULL, + type VARCHAR(20) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + revolution BIGINT NOT NULL, + lat DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + duration DOUBLE PRECISION NOT NULL, + contour_wkt TEXT, + roll DOUBLE PRECISION NOT NULL, + cell_num BIGINT NOT NULL DEFAULT -1, + CONSTRAINT chk_satellite_modes_source CHECK (source IN ('SLOTS', 'COMPLAN', 'MIXED')), + CONSTRAINT chk_satellite_modes_type CHECK (type IN ('SURVEY')) +); + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_satellite_id + ON satellite_modes(satellite_id); + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_start_time + ON satellite_modes(start_time); + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_end_time + ON satellite_modes(end_time); + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_source + ON satellite_modes(source); + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_satellite_interval + ON satellite_modes(satellite_id, start_time, end_time); diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V2__satellite_mode_booked_slot.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V2__satellite_mode_booked_slot.sql new file mode 100644 index 0000000..3cecec8 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V2__satellite_mode_booked_slot.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS satellite_mode_booked_slot ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + mode_id BIGINT NOT NULL REFERENCES satellite_modes(id) ON DELETE CASCADE, + booked_slot_id BIGINT NOT NULL, + CONSTRAINT uq_satellite_mode_booked_slot_mode_booked_slot UNIQUE(mode_id, booked_slot_id) +); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_booked_slot_mode_id + ON satellite_mode_booked_slot(mode_id); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_booked_slot_booked_slot_id + ON satellite_mode_booked_slot(booked_slot_id); diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V3__satellite_mode_source_mixed.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V3__satellite_mode_source_mixed.sql new file mode 100644 index 0000000..31ee07e --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V3__satellite_mode_source_mixed.sql @@ -0,0 +1,6 @@ +ALTER TABLE satellite_modes + DROP CONSTRAINT IF EXISTS chk_satellite_modes_source; + +ALTER TABLE satellite_modes + ADD CONSTRAINT chk_satellite_modes_source + CHECK (source IN ('SLOTS', 'COMPLAN', 'MIXED')); diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V4__satellite_mode_cell.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V4__satellite_mode_cell.sql new file mode 100644 index 0000000..0f10b98 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V4__satellite_mode_cell.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS satellite_mode_cell ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + mode_id BIGINT NOT NULL REFERENCES satellite_modes(id) ON DELETE CASCADE, + cell_num BIGINT NOT NULL, + CONSTRAINT uq_satellite_mode_cell_mode_cell UNIQUE(mode_id, cell_num) +); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_cell_mode_id + ON satellite_mode_cell(mode_id); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_cell_cell_num + ON satellite_mode_cell(cell_num); + +INSERT INTO satellite_mode_cell (mode_id, cell_num) +SELECT id, cell_num +FROM satellite_modes +WHERE cell_num > 0 +ON CONFLICT DO NOTHING; diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V5__complex_plan_run.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V5__complex_plan_run.sql new file mode 100644 index 0000000..71d19bf --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V5__complex_plan_run.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS complex_plan_run ( + id BIGSERIAL PRIMARY KEY, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP, + finished_at TIMESTAMP, + duration_ms BIGINT, + interval_start TIMESTAMP NOT NULL, + interval_end TIMESTAMP NOT NULL, + requested_by VARCHAR(255), + error_message TEXT, + calculated_modes_count INTEGER NOT NULL DEFAULT 0, + booked_modes_count INTEGER NOT NULL DEFAULT 0, + complan_modes_count INTEGER NOT NULL DEFAULT 0, + mixed_modes_count INTEGER NOT NULL DEFAULT 0, + affected_satellites_count INTEGER NOT NULL DEFAULT 0, + processed_cells_count INTEGER NOT NULL DEFAULT 0, + booked_slot_links_count INTEGER NOT NULL DEFAULT 0, + request_payload TEXT, + CONSTRAINT chk_complex_plan_run_status CHECK (status IN ('CREATED', 'RUNNING', 'COMPLETED', 'FAILED')) +); + +CREATE INDEX IF NOT EXISTS idx_complex_plan_run_status + ON complex_plan_run(status); + +CREATE INDEX IF NOT EXISTS idx_complex_plan_run_created_at + ON complex_plan_run(created_at); + +CREATE INDEX IF NOT EXISTS idx_complex_plan_run_interval_start + ON complex_plan_run(interval_start); + +CREATE INDEX IF NOT EXISTS idx_complex_plan_run_interval_end + ON complex_plan_run(interval_end); + +CREATE TABLE IF NOT EXISTS complex_plan_run_satellite ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES complex_plan_run(id) ON DELETE CASCADE, + satellite_id BIGINT NOT NULL, + CONSTRAINT uq_complex_plan_run_satellite_run_satellite UNIQUE (run_id, satellite_id) +); + +CREATE INDEX IF NOT EXISTS idx_complex_plan_run_satellite_run_id + ON complex_plan_run_satellite(run_id); + +CREATE INDEX IF NOT EXISTS idx_complex_plan_run_satellite_satellite_id + ON complex_plan_run_satellite(satellite_id); + +ALTER TABLE satellite_modes + ADD COLUMN IF NOT EXISTS complex_plan_run_id BIGINT REFERENCES complex_plan_run(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_complex_plan_run_id + ON satellite_modes(complex_plan_run_id); diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V6__satellite_mode_source_manual.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V6__satellite_mode_source_manual.sql new file mode 100644 index 0000000..55e09f0 --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V6__satellite_mode_source_manual.sql @@ -0,0 +1,6 @@ +ALTER TABLE satellite_modes + DROP CONSTRAINT IF EXISTS chk_satellite_modes_source; + +ALTER TABLE satellite_modes + ADD CONSTRAINT chk_satellite_modes_source + CHECK (source IN ('SLOTS', 'COMPLAN', 'MIXED', 'MANUAL')); diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V7__satellite_mode_snapshots.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V7__satellite_mode_snapshots.sql new file mode 100644 index 0000000..9dac3bf --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V7__satellite_mode_snapshots.sql @@ -0,0 +1,93 @@ +CREATE TABLE IF NOT EXISTS satellite_mode_snapshots ( + id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + interval_start TIMESTAMP NOT NULL, + interval_end TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + complex_plan_run_id BIGINT REFERENCES complex_plan_run(id) ON DELETE SET NULL, + comment TEXT +); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_satellite_id + ON satellite_mode_snapshots(satellite_id); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_interval_start + ON satellite_mode_snapshots(interval_start); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_interval_end + ON satellite_mode_snapshots(interval_end); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_is_active + ON satellite_mode_snapshots(is_active); + +CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_run_id + ON satellite_mode_snapshots(complex_plan_run_id); + +ALTER TABLE satellite_modes + ADD COLUMN IF NOT EXISTS snapshot_id BIGINT REFERENCES satellite_mode_snapshots(id) ON DELETE CASCADE; + +INSERT INTO satellite_mode_snapshots ( + satellite_id, + interval_start, + interval_end, + created_at, + is_active, + complex_plan_run_id, + comment +) +SELECT + sm.satellite_id, + COALESCE(cpr.interval_start, MIN(sm.start_time)), + COALESCE(cpr.interval_end, MAX(sm.end_time)), + COALESCE(cpr.created_at, CURRENT_TIMESTAMP), + TRUE, + sm.complex_plan_run_id, + CASE + WHEN sm.complex_plan_run_id IS NULL THEN 'legacy-backfill-no-run' + ELSE 'legacy-backfill-from-run' + END +FROM satellite_modes sm +LEFT JOIN complex_plan_run cpr ON cpr.id = sm.complex_plan_run_id +WHERE sm.snapshot_id IS NULL +GROUP BY + sm.satellite_id, + sm.complex_plan_run_id, + cpr.interval_start, + cpr.interval_end, + cpr.created_at; + +UPDATE satellite_modes sm +SET snapshot_id = snapshots.id +FROM satellite_mode_snapshots snapshots +WHERE sm.snapshot_id IS NULL + AND snapshots.satellite_id = sm.satellite_id + AND ( + (sm.complex_plan_run_id IS NOT NULL AND snapshots.complex_plan_run_id = sm.complex_plan_run_id) + OR + (sm.complex_plan_run_id IS NULL AND snapshots.complex_plan_run_id IS NULL) + ); + +WITH ranked_snapshots AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY satellite_id, interval_start, interval_end + ORDER BY created_at DESC, id DESC + ) AS rn + FROM satellite_mode_snapshots +) +UPDATE satellite_mode_snapshots snapshots +SET is_active = CASE WHEN ranked_snapshots.rn = 1 THEN TRUE ELSE FALSE END +FROM ranked_snapshots +WHERE ranked_snapshots.id = snapshots.id; + +ALTER TABLE satellite_modes + ALTER COLUMN snapshot_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_satellite_modes_snapshot_id + ON satellite_modes(snapshot_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_satellite_mode_snapshots_active_per_interval + ON satellite_mode_snapshots(satellite_id, interval_start, interval_end) + WHERE is_active = TRUE; diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V8__complex_plan_run_coverage_area.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V8__complex_plan_run_coverage_area.sql new file mode 100644 index 0000000..16ba74b --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V8__complex_plan_run_coverage_area.sql @@ -0,0 +1,4 @@ +ALTER TABLE complex_plan_run + ADD COLUMN IF NOT EXISTS total_requests_area DOUBLE PRECISION NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS covered_area DOUBLE PRECISION NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS covered_area_percent DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplicationTests.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplicationTests.kt new file mode 100644 index 0000000..03c3fcf --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/PcpBallisticsServiceApplicationTests.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_satellites_service + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class PcpBallisticsServiceApplicationTests { + + @Test + fun contextLoads() { + } + +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt new file mode 100644 index 0000000..669a3db --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt @@ -0,0 +1,119 @@ +package space.nstart.pcp.pcp_satellites_service.controller + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +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 space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(OutputCaptureExtension::class) +class ComplexPlanControllerTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var complexMissionService: ComplexMissionService + + @MockitoBean + private lateinit var complexPlanRunOrchestrationService: ComplexPlanRunOrchestrationService + + @Test + fun `process endpoint uses POST and logs response body as json`(output: CapturedOutput) { + val request = ComplexPlanProcessRequestDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L) + ) + doReturn( + ComplexPlanRunResponseDTO( + runId = 1L, + status = ComplexPlanRunStatus.COMPLETED, + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + requestedBy = "tester", + createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC), + startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC), + finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC), + durationMs = 60000, + errorMessage = null, + calculatedModesCount = 1, + bookedModesCount = 1, + complanModesCount = 0, + mixedModesCount = 0, + affectedSatellitesCount = 1, + processedCellsCount = 1, + bookedSlotLinksCount = 1, + snapshotIds = listOf(1001L), + requestPayload = "{}" + ) + ).`when`(complexPlanRunOrchestrationService).process(request, "tester") + + WebClient.create("http://localhost:$port") + .post() + .uri("/api/com-plan/process") + .header("X-Requested-By", "tester") + .bodyValue( + mapOf( + "intervalStart" to "2026-03-26T10:00:00", + "intervalEnd" to "2026-03-26T12:00:00", + "satelliteIds" to listOf(22) + ) + ) + .retrieve() + .bodyToMono(String::class.java) + .block() + + verify(complexPlanRunOrchestrationService).process( + request, + "tester" + ) + assertContains(output.out, "Complex plan process completed: runId=1, status=COMPLETED") + assertContains(output.out, "responseBody={") + assertContains(output.out, """"runId":1""") + assertContains(output.out, """"status":"COMPLETED"""") + } + + @Test + fun `process endpoint rejects sun outside allowed range`() { + val response = WebClient.create("http://localhost:$port") + .post() + .uri("/api/com-plan/process") + .bodyValue( + mapOf( + "intervalStart" to "2026-03-26T10:00:00", + "intervalEnd" to "2026-03-26T12:00:00", + "satelliteIds" to listOf(22), + "sun" to 90.1 + ) + ) + .exchangeToMono { clientResponse -> + clientResponse.bodyToMono(String::class.java) + .defaultIfEmpty("") + .map { body -> clientResponse.statusCode().value() to body } + } + .block()!! + + assertEquals(400, response.first) + assertTrue(response.second.contains("sun")) + verifyNoInteractions(complexPlanRunOrchestrationService) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt new file mode 100644 index 0000000..83a23c6 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt @@ -0,0 +1,212 @@ +package space.nstart.pcp.pcp_satellites_service.controller + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistenceService +import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ComplexPlanRunApiTest { + + @LocalServerPort + private var port: Int = 0 + + @Autowired + private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService + + @Autowired + private lateinit var complexPlanRunRepository: ComplexPlanRunRepository + + @Autowired + private lateinit var satelliteModeRepository: SatelliteModeRepository + + @Autowired + private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository + + @Autowired + private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository + + @Autowired + private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository + + @Autowired + private lateinit var satelliteModePersistenceService: SatelliteModePersistenceService + + @BeforeEach + fun setUp() { + satelliteModeBookedSlotRepository.deleteAll() + satelliteModeCellRepository.deleteAll() + satelliteModeRepository.deleteAll() + satelliteModeSnapshotRepository.deleteAll() + complexPlanRunRepository.deleteAll() + } + + @Test + fun `run details endpoint returns persisted run`() { + val runId = createCompletedRun(listOf(22L), "api-user") + + val response = WebClient.create("http://localhost:$port") + .get() + .uri("/api/com-plan/runs/$runId") + .retrieve() + .bodyToMono(ComplexPlanRunResponseDTO::class.java) + .block()!! + + assertEquals(runId, response.runId) + assertEquals(ComplexPlanRunStatus.COMPLETED, response.status) + assertEquals(listOf(22L), response.satelliteIds) + assertEquals("api-user", response.requestedBy) + assertEquals(3, response.calculatedModesCount) + assertEquals(10.0, response.totalRequestsArea) + assertEquals(4.0, response.coveredArea) + assertEquals(40.0, response.coveredAreaPercent) + assertEquals(ZoneOffset.UTC, response.createdAt.offset) + } + + @Test + fun `runs endpoint supports status and satellite filter`() { + val completedRunId = createCompletedRun(listOf(22L), "one") + createFailedRun(listOf(55L)) + + val response = WebClient.create("http://localhost:$port") + .get() + .uri("/api/com-plan/runs?status=COMPLETED&satelliteId=22") + .retrieve() + .bodyToFlux(ComplexPlanRunSummaryDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(1, response.size) + assertEquals(completedRunId, response.single().runId) + assertEquals(ComplexPlanRunStatus.COMPLETED, response.single().status) + assertEquals(listOf(22L), response.single().satelliteIds) + assertEquals(40.0, response.single().coveredAreaPercent) + } + + @Test + fun `delete run endpoint removes persisted run`() { + val runId = createCompletedRun(listOf(22L), "api-user") + + WebClient.create("http://localhost:$port") + .delete() + .uri("/api/com-plan/runs/$runId") + .retrieve() + .toBodilessEntity() + .block() + + assertFalse(complexPlanRunRepository.existsById(runId)) + } + + @Test + fun `run modes endpoint returns modes sorted by time`() { + val runId = createCompletedRun(listOf(22L), "api-user") + val run = complexPlanRunRepository.findById(runId).orElseThrow() + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 2, + time = intervalStart.plusMinutes(20), + timeStop = intervalStart.plusMinutes(30), + roll = 5.0, + contourWKT = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", + source = SatelliteModeSource.COMPLAN + ), + SurveyMode( + revolution = 1, + time = intervalStart.plusMinutes(5), + timeStop = intervalStart.plusMinutes(10), + roll = 4.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN, + cellNums = listOf(42L) + ) + ) + ), + complexPlanRun = run + ) + + val response = WebClient.create("http://localhost:$port") + .get() + .uri("/api/com-plan/runs/$runId/modes") + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(listOf(1L, 2L), response.map { it.revolution }) + assertEquals(listOf(42L), response.first().cellNums) + } + + private fun createCompletedRun(satelliteIds: List, requestedBy: String?): Long { + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = satelliteIds, + requestedBy = requestedBy, + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(run.id!!) + complexPlanRunPersistenceService.markCompleted( + run.id!!, + ComplexPlanRunStatisticsDTO( + calculatedModesCount = 3, + bookedModesCount = 1, + complanModesCount = 1, + mixedModesCount = 1, + affectedSatellitesCount = satelliteIds.size, + processedCellsCount = 2, + bookedSlotLinksCount = 4, + totalRequestsArea = 10.0, + coveredArea = 4.0, + coveredAreaPercent = 40.0 + ) + ) + return run.id!! + } + + private fun createFailedRun(satelliteIds: List): Long { + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 15, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 16, 0), + satelliteIds = satelliteIds, + requestedBy = null, + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(run.id!!) + complexPlanRunPersistenceService.markFailed(run.id!!, "boom") + return run.id!! + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/SatelliteModeApiTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/SatelliteModeApiTest.kt new file mode 100644 index 0000000..52bff96 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/SatelliteModeApiTest.kt @@ -0,0 +1,352 @@ +package space.nstart.pcp.pcp_satellites_service.controller + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistenceService +import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SatelliteModeApiTest { + + @LocalServerPort + private var port: Int = 0 + + @Autowired + private lateinit var satelliteModePersistenceService: SatelliteModePersistenceService + + @Autowired + private lateinit var satelliteModeRepository: SatelliteModeRepository + + @Autowired + private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository + + @Autowired + private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository + + @Autowired + private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository + + @Autowired + private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService + + @Autowired + private lateinit var complexPlanRunRepository: ComplexPlanRunRepository + + @BeforeEach + fun setUp() { + satelliteModeBookedSlotRepository.deleteAll() + satelliteModeCellRepository.deleteAll() + satelliteModeRepository.deleteAll() + satelliteModeSnapshotRepository.deleteAll() + complexPlanRunRepository.deleteAll() + } + + @Test + fun `modes endpoint returns interval-filtered modes from active snapshot regardless of snapshot interval`() { + val snapshotStart = LocalDateTime.of(2026, 3, 26, 8, 0) + val snapshotEnd = LocalDateTime.of(2026, 3, 26, 14, 0) + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = snapshotStart, + intervalEnd = snapshotEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 100, + time = intervalStart.plusMinutes(1), + timeStop = intervalStart.plusMinutes(10), + roll = 5.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN, + bookedSlotIds = listOf(777L), + cellNums = listOf(42L, 43L) + ) + ) + ) + ) + + val response = WebClient.create("http://localhost:$port") + .get() + .uri("/api/satellites/modes?intervalStart=2026-03-26T10:00:00&intervalEnd=2026-03-26T12:00:00&satelliteId=22") + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(1, response.size) + assertEquals(22L, response.single().satelliteId) + assertEquals("COMPLAN", response.single().source) + assertEquals(listOf(777L), response.single().bookedSlotIds) + assertEquals(listOf(42L, 43L), response.single().cellNums) + assertNotNull(response.single().snapshotId) + } + + @Test + fun `satellite scoped modes endpoint returns interval-filtered modes from active snapshot`() { + val snapshotStart = LocalDateTime.of(2026, 3, 27, 8, 0) + val snapshotEnd = LocalDateTime.of(2026, 3, 27, 14, 0) + val intervalStart = LocalDateTime.of(2026, 3, 27, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = snapshotStart, + intervalEnd = snapshotEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 101, + time = intervalStart.plusMinutes(2), + timeStop = intervalStart.plusMinutes(8), + roll = 6.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.MANUAL, + bookedSlotIds = listOf(778L), + cellNums = listOf(44L) + ) + ) + ) + ) + + val response = WebClient.create("http://localhost:$port") + .get() + .uri("/api/satellites/22/modes?intervalStart=2026-03-27T10:00:00&intervalEnd=2026-03-27T12:00:00") + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(1, response.size) + assertEquals("MANUAL", response.single().source) + assertEquals(listOf(778L), response.single().bookedSlotIds) + } + + @Test + fun `satellite scoped modes endpoint returns modes for requested runId`() { + val intervalStart = LocalDateTime.of(2026, 3, 27, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + val firstRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + val secondRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 201, + time = intervalStart.plusMinutes(2), + timeStop = intervalStart.plusMinutes(8), + roll = 6.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN + ) + ) + ), + complexPlanRun = firstRun + ) + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 202, + time = intervalStart.plusMinutes(3), + timeStop = intervalStart.plusMinutes(9), + roll = 7.0, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + source = SatelliteModeSource.MANUAL + ) + ) + ), + complexPlanRun = secondRun + ) + + val response = WebClient.create("http://localhost:$port") + .get() + .uri( + "/api/satellites/22/modes" + + "?intervalStart=2026-03-27T10:00:00" + + "&intervalEnd=2026-03-27T12:00:00" + + "&runId=${firstRun.id}" + ) + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(1, response.size) + assertEquals(201L, response.single().revolution) + assertEquals("COMPLAN", response.single().source) + } + + @Test + fun `active snapshot endpoint ignores interval and resolves by satellite only`() { + val snapshotStart = LocalDateTime.of(2026, 3, 30, 8, 0) + val snapshotEnd = LocalDateTime.of(2026, 3, 30, 14, 0) + val queryStart = LocalDateTime.of(2026, 3, 30, 10, 0) + val queryEnd = LocalDateTime.of(2026, 3, 30, 11, 0) + val snapshotId = satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = snapshotStart, + intervalEnd = snapshotEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 301, + time = queryStart.plusMinutes(5), + timeStop = queryStart.plusMinutes(15), + roll = 5.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN + ) + ) + ) + ).snapshotIds.single() + + val response = WebClient.create("http://localhost:$port") + .get() + .uri("/api/satellites/22/snapshots/active?intervalStart=$queryStart&intervalEnd=$queryEnd") + .retrieve() + .bodyToMono(SatelliteModeSnapshotResponseDTO::class.java) + .block()!! + + assertEquals(snapshotId, response.id) + assertEquals(22L, response.satelliteId) + assertEquals(snapshotStart, response.intervalStart) + assertEquals(snapshotEnd, response.intervalEnd) + } + + @Test + fun `snapshot endpoints return metadata and modes for explicit snapshot`() { + val intervalStart = LocalDateTime.of(2026, 3, 28, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 28, 12, 0) + val snapshotId = satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 102, + time = intervalStart.plusMinutes(3), + timeStop = intervalStart.plusMinutes(9), + roll = 6.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN + ) + ) + ) + ).snapshotIds.single() + + val snapshot = WebClient.create("http://localhost:$port") + .get() + .uri("/api/satellites/snapshots/$snapshotId") + .retrieve() + .bodyToMono(SatelliteModeSnapshotResponseDTO::class.java) + .block()!! + val modes = WebClient.create("http://localhost:$port") + .get() + .uri("/api/satellites/snapshots/$snapshotId/modes") + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(snapshotId, snapshot.id) + assertEquals(22L, snapshot.satelliteId) + assertEquals(intervalStart, snapshot.intervalStart) + assertEquals(intervalEnd, snapshot.intervalEnd) + assertEquals(true, snapshot.isActive) + assertEquals(listOf(snapshotId), modes.mapNotNull { it.snapshotId }.distinct()) + } + + @Test + fun `snapshot modes endpoint filters by requested interval`() { + val snapshotStart = LocalDateTime.of(2026, 3, 29, 8, 0) + val snapshotEnd = LocalDateTime.of(2026, 3, 29, 14, 0) + val requestedStart = LocalDateTime.of(2026, 3, 29, 10, 0) + val requestedEnd = LocalDateTime.of(2026, 3, 29, 11, 0) + val snapshotId = satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = snapshotStart, + intervalEnd = snapshotEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 201, + time = snapshotStart.plusMinutes(10), + timeStop = snapshotStart.plusMinutes(20), + roll = 5.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN + ), + SurveyMode( + revolution = 202, + time = requestedStart.plusMinutes(5), + timeStop = requestedStart.plusMinutes(15), + roll = 5.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.MANUAL + ) + ) + ) + ).snapshotIds.single() + + val modes = WebClient.create("http://localhost:$port") + .get() + .uri( + "/api/satellites/snapshots/$snapshotId/modes?intervalStart=$requestedStart&intervalEnd=$requestedEnd" + ) + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(1, modes.size) + assertEquals(202L, modes.single().revolution) + assertEquals(snapshotId, modes.single().snapshotId) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanProcessRequestDTOValidationTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanProcessRequestDTOValidationTest.kt new file mode 100644 index 0000000..800285f --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanProcessRequestDTOValidationTest.kt @@ -0,0 +1,88 @@ +package space.nstart.pcp.pcp_satellites_service.dto + +import jakarta.validation.Validation +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ComplexPlanProcessRequestDTOValidationTest { + + private val validator = Validation.buildDefaultValidatorFactory().validator + private val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + private val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + @Test + fun `sun null is valid`() { + assertTrue(validate(null).isEmpty()) + } + + @Test + fun `sun minus 90 is valid`() { + assertTrue(validate(-90.0).isEmpty()) + } + + @Test + fun `sun 90 is valid`() { + assertTrue(validate(90.0).isEmpty()) + } + + @Test + fun `sun below minus 90 is invalid`() { + val violations = validate(-90.1) + + assertEquals(1, violations.size) + assertEquals("sun", violations.single().propertyPath.toString()) + } + + @Test + fun `sun above 90 is invalid`() { + val violations = validate(90.1) + + assertEquals(1, violations.size) + assertEquals("sun", violations.single().propertyPath.toString()) + } + + @Test + fun `aggregation counts null are valid`() { + assertTrue(validate(sun = null, countLat = null, countLong = null).isEmpty()) + } + + @Test + fun `aggregation counts one are valid`() { + assertTrue(validate(sun = null, countLat = 1, countLong = 1).isEmpty()) + } + + @Test + fun `countLat below one is invalid`() { + val violations = validate(sun = null, countLat = 0, countLong = 1) + + assertEquals(1, violations.size) + assertEquals("countLat", violations.single().propertyPath.toString()) + } + + @Test + fun `countLong below one is invalid`() { + val violations = validate(sun = null, countLat = 1, countLong = 0) + + assertEquals(1, violations.size) + assertEquals("countLong", violations.single().propertyPath.toString()) + } + + private fun validate( + sun: Double?, + countLat: Int? = null, + countLong: Int? = null + ) = + validator.validate( + ComplexPlanProcessRequestDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + sun = sun, + countLat = countLat, + countLong = countLong + ) + ) +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyModeTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyModeTest.kt new file mode 100644 index 0000000..a86fef4 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyModeTest.kt @@ -0,0 +1,33 @@ +package space.nstart.pcp.pcp_satellites_service.model.mode + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class SurveyModeTest { + + @Test + fun `toDTO preserves slot ids and coordinates`() { + val timeStart = LocalDateTime.of(2026, 3, 31, 12, 0) + val timeStop = timeStart.plusMinutes(10) + val surveyMode = SurveyMode( + revolution = 42, + time = timeStart, + timeStop = timeStop, + roll = 3.5, + latitude = 55.75, + longitude = 37.62, + duration = 600.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 0))", + bookedSlotIds = listOf(10L, 20L) + ) + + val dto = surveyMode.toDTO() + + assertEquals(timeStart, dto.time) + assertEquals(timeStop, dto.timStop) + assertEquals(listOf(10L, 20L), dto.slotIds) + assertEquals(55.75, dto.latitude) + assertEquals(37.62, dto.longitude) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationServiceTest.kt new file mode 100644 index 0000000..a71951c --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationServiceTest.kt @@ -0,0 +1,776 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyMap +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.ArgumentMatchers.isNull +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.`when` +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.stubbing.Answer +import space.nstart.pcp.pcp_satellites_service.configuration.ComplexPlanProperties +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.BLS +import space.nstart.pcp.pcp_satellites_service.model.LongTermMission +import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +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.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ComplexPlanCalculationServiceTest { + + private fun anyListValue(): List = anyList() ?: emptyList() + @Suppress("UNCHECKED_CAST") + private fun anySurveyMap(): Map> = + anyMap>() as Map>? ?: emptyMap() + private fun eqValue(value: T): T = eq(value) ?: value + private fun captureMap(captor: ArgumentCaptor>>): Map> = + captor.capture() ?: emptyMap() + private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN + @Suppress("UNCHECKED_CAST") + private fun anyMutableLongList(): MutableList = + (any(MutableList::class.java) as MutableList?) ?: mutableListOf() + @Suppress("UNCHECKED_CAST") + private fun anyOccupiedIntervals(): List = + (any(List::class.java) as List?) ?: emptyList() + private fun captureOccupiedIntervals(captor: ArgumentCaptor>): List = + captor.capture() ?: emptyList() + private fun props(chunkSize: Int = 100) = ComplexPlanProperties().apply { batchChunkSize = chunkSize } + private fun coverageBatchClient(): CoverageBatchClient = + mock(CoverageBatchClient::class.java).also { + `when`(it.source).thenReturn(CoverageCalculationSource.SLOTS) + } + private fun coverageResolver(vararg clients: CoverageCalculationClient) = + CoverageCalculationClientResolver(clients.toList()) + + @Test + fun `process calculates first and then atomically replaces persisted result`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = SatelliteService(testSatelliteCatalogClient()) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 11, 0) + val cell = EarthCellWithRequestsDTO( + id = 100, + num = 42, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + requests = emptyList() + ) + + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn(emptyMap()) + `when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(cell)) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn( + mapOf( + 22L to listOf( + SurveySlotDTO( + satelliteId = 22, + tn = intervalStart.plusMinutes(5), + tk = intervalStart.plusMinutes(15), + roll = 10.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 501, + slotIds = listOf(9001) + ) + ) + ) + ) + doReturn( + mapOf( + 100L to listOf( + SlotDTO( + cycle = 0, + satelliteId = 22, + tn = intervalStart.plusMinutes(10), + tk = intervalStart.plusMinutes(20), + roll = 10.0, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", + revolution = 501, + latitude = 10.0, + longitude = 20.0 + ) + ) + ) + ).`when`(coverageBatchClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + doReturn(SatelliteModeSaveResult(calculatedModesCount = 1, bookedModesCount = 1, affectedSatellitesCount = 1)) + .`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + service.process(intervalStart, intervalEnd, listOf(22L)) + + val inOrder = inOrder(complexPlanPersistenceService, complexPlanQueryService, coverageBatchClient) + inOrder.verify(complexPlanQueryService).loadConstraintModes(listOf(22L), intervalStart, intervalEnd) + inOrder.verify(coverageBatchClient).plannedModes(listOf(22L), intervalStart, intervalEnd) + inOrder.verify(coverageBatchClient).coverageModes( + anyListValue(), + eqValue(intervalStart), + eqValue(intervalEnd), + eqValue(listOf(22L)), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + inOrder.verify(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + val surveysCaptor = ArgumentCaptor.forClass(Map::class.java) as ArgumentCaptor>> + verify(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + captureMap(surveysCaptor), + isNull() + ) + + val savedSurvey = surveysCaptor.value.getValue(22L).single() + assertEquals(SatelliteModeSource.SLOTS, savedSurvey.source) + assertEquals(listOf(9001L), savedSurvey.bookedSlotIds) + assertTrue(savedSurvey.time <= intervalStart.plusMinutes(5)) + assertTrue(savedSurvey.timeStop >= intervalStart.plusMinutes(15)) + } + + @Test + fun `process returns covered area percent by survey area union`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = SatelliteService(testSatelliteCatalogClient()) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 11, 0) + val requestContour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))" + val coveredContour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + val cell = EarthCellWithRequestsDTO( + id = 100, + num = 42, + contour = requestContour, + requests = listOf( + CellRequestDOT( + requestId = "request-1", + contour = requestContour + ) + ) + ) + + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn(emptyMap()) + `when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(cell)) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + doReturn( + mapOf( + 100L to listOf( + SlotDTO( + cycle = 0, + satelliteId = 22, + tn = intervalStart.plusMinutes(10), + tk = intervalStart.plusMinutes(20), + roll = 10.0, + contour = coveredContour, + revolution = 501, + latitude = 10.0, + longitude = 20.0 + ) + ) + ) + ).`when`(coverageBatchClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + doReturn(SatelliteModeSaveResult(calculatedModesCount = 1, complanModesCount = 1, affectedSatellitesCount = 1)) + .`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + val result = service.process(intervalStart, intervalEnd, listOf(22L)) + + assertEquals(2.0, result.totalRequestsArea, 0.0001) + assertEquals(1.0, result.coveredArea, 0.0001) + assertEquals(50.0, result.coveredAreaPercent, 0.0001) + } + + @Test + fun `process calculates total requests area from all request cell fragments`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = SatelliteService(testSatelliteCatalogClient()) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 11, 0) + val firstRequestFragment = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + val secondRequestFragment = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + val cells = listOf( + EarthCellWithRequestsDTO( + id = 100, + num = 42, + contour = firstRequestFragment, + requests = listOf( + CellRequestDOT( + id = 1001, + requestId = "request-1", + contour = firstRequestFragment + ) + ) + ), + EarthCellWithRequestsDTO( + id = 101, + num = 43, + contour = secondRequestFragment, + requests = listOf( + CellRequestDOT( + id = 1002, + requestId = "request-1", + contour = secondRequestFragment + ) + ) + ) + ) + + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn(emptyMap()) + `when`(earthGridService.cells(isNull(), isNull())).thenReturn(cells) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + doReturn(emptyMap>()) + .`when`(coverageBatchClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + doReturn(SatelliteModeSaveResult()) + .`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + val result = service.process(intervalStart, intervalEnd, listOf(22L)) + + assertEquals(2.0, result.totalRequestsArea, 0.0001) + assertEquals(0.0, result.coveredArea, 0.0001) + } + + @Test + fun `process requests coverage by chunks and stops before next chunk when resource is exhausted`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(chunkSize = 2), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 10, 0) + val cells = listOf( + EarthCellWithRequestsDTO(id = 1, num = 1, importance = 10.0, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", requests = emptyList()), + EarthCellWithRequestsDTO(id = 2, num = 2, importance = 9.0, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", requests = emptyList()), + EarthCellWithRequestsDTO(id = 3, num = 3, importance = 8.0, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))", requests = emptyList()) + ) + + `when`(satelliteService.satellites).thenReturn( + listOf( + SatelliteModel( + satelliteId = 22L, + name = "T", + bls = BLS(durationMax = 900.0, dailyMaxDuration = 605.0, revolutionMaxDuration = 2_000.0) + ) + ) + ) + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn(emptyMap()) + `when`(earthGridService.cells(isNull(), isNull())).thenReturn(cells) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + doReturn( + mapOf( + 1L to listOf( + SlotDTO( + cycle = 0, + satelliteId = 22, + tn = intervalStart.plusMinutes(5), + tk = intervalStart.plusMinutes(15), + roll = 8.0, + contour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + revolution = 501, + latitude = 10.0, + longitude = 20.0 + ) + ) + ) + ).`when`(coverageBatchClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + service.process(intervalStart, intervalEnd, listOf(22L)) + + verify(coverageBatchClient, times(1)) + .coverageModes( + anyListValue(), + eqValue(intervalStart), + eqValue(intervalEnd), + eqValue(listOf(22L)), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + } + + @Test + fun `process passes occupied intervals snapshot and refreshes it between chunks`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(chunkSize = 1), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 10, 0) + val satellite = SatelliteModel( + satelliteId = 22L, + name = "T", + longMission = LongTermMission(), + bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0) + ) + val firstCell = EarthCellWithRequestsDTO( + id = 1, + num = 101, + importance = 10.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + requests = emptyList() + ) + val secondCell = EarthCellWithRequestsDTO( + id = 2, + num = 102, + importance = 9.0, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", + requests = emptyList() + ) + + `when`(satelliteService.satellites).thenReturn(listOf(satellite)) + `when`(satelliteService.hasResources(eqValue(intervalStart), eqValue(intervalEnd), eqValue(listOf(22L)), anyMutableLongList())) + .thenAnswer(Answer { + val available = it.arguments[3] as MutableList + available.clear() + available += 22L + true + }) + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn( + mapOf( + 22L to mutableListOf( + SurveyMode( + revolution = 500, + time = intervalStart.minusMinutes(20), + timeStop = intervalStart.minusMinutes(10), + roll = 12.5, + duration = 600.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.SLOTS + ) + ) + ) + ) + `when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(firstCell, secondCell)) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + doReturn( + mapOf( + 1L to listOf( + SlotDTO( + cycle = 0, + satelliteId = 22, + tn = intervalStart.plusMinutes(5), + tk = intervalStart.plusMinutes(15), + roll = 8.0, + contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))", + revolution = 501, + latitude = 10.0, + longitude = 20.0 + ) + ) + ), + emptyMap>() + ).`when`(coverageBatchClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + service.process(intervalStart, intervalEnd, listOf(22L)) + + val occupiedCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor> + verify(coverageBatchClient, times(2)).coverageModes( + anyListValue(), + eqValue(intervalStart), + eqValue(intervalEnd), + eqValue(listOf(22L)), + captureOccupiedIntervals(occupiedCaptor), + isNull(), + isNull() + ) + + val firstSnapshot = occupiedCaptor.allValues[0] + val secondSnapshot = occupiedCaptor.allValues[1] + + assertEquals(1, firstSnapshot.size) + assertEquals(2, secondSnapshot.size) + assertTrue(secondSnapshot.any { it.roll == 8.0 && it.startTime == intervalStart.plusMinutes(5) && it.endTime == intervalStart.plusMinutes(15) }) + } + + @Test + fun `process failure before replace does not touch persisted result`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + + `when`(satelliteService.satellites).thenReturn( + listOf( + SatelliteModel( + satelliteId = 22L, + name = "T", + bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0) + ) + ) + ) + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)) + .thenReturn(emptyMap()) + org.mockito.Mockito.doThrow(RuntimeException("cells boom")) + .`when`(earthGridService).cells(isNull(), isNull()) + + kotlin.test.assertFailsWith { + service.process(intervalStart, intervalEnd, listOf(22L)) + } + + verify(complexPlanPersistenceService, never()).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + } + + @Test + fun `process passes sun filter to coverage batch client`() { + val coverageBatchClient = coverageBatchClient() + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + val requestedSun = 12.5 + val cell = EarthCellWithRequestsDTO( + id = 100, + num = 42, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + requests = emptyList() + ) + var capturedSun: Double? = null + + `when`(satelliteService.satellites).thenReturn( + listOf( + SatelliteModel( + satelliteId = 22L, + name = "T", + bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0) + ) + ) + ) + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + `when`(earthGridService.cells(eqValue(2), eqValue(3))).thenReturn(listOf(cell)) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + org.mockito.Mockito.doAnswer { invocation -> + capturedSun = invocation.arguments[5] as Double? + emptyMap>() + }.`when`(coverageBatchClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + org.mockito.ArgumentMatchers.nullable(Double::class.java), + isNull() + ) + doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + service.process( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + sun = requestedSun, + countLat = 2, + countLong = 3 + ) + + verify(earthGridService).cells(2, 3) + verify(coverageBatchClient, org.mockito.Mockito.atLeastOnce()).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + org.mockito.ArgumentMatchers.nullable(Double::class.java), + isNull() + ) + assertEquals(requestedSun, capturedSun) + } + + @Test + fun `process uses coverage scheme client when requested`() { + val coverageBatchClient = coverageBatchClient() + val coverageSchemeClient = mock(CoverageCalculationClient::class.java) + val earthGridService = mock(EarthGridService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val complexPlanQueryService = mock(ComplexPlanQueryService::class.java) + val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java) + val surveyPlacementService = SurveyPlacementService() + + `when`(coverageSchemeClient.source).thenReturn(CoverageCalculationSource.COVERAGE_SCHEME) + + val service = ComplexPlanCalculationService( + coverageBatchClient = coverageBatchClient, + coverageCalculationClientResolver = coverageResolver(coverageBatchClient, coverageSchemeClient), + earthGridService = earthGridService, + satelliteService = satelliteService, + complexPlanQueryService = complexPlanQueryService, + complexPlanPersistenceService = complexPlanPersistenceService, + complexPlanProperties = props(), + surveyPlacementService = surveyPlacementService + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + val cell = EarthCellWithRequestsDTO( + id = 100, + num = 42, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + requests = emptyList() + ) + + `when`(satelliteService.satellites).thenReturn( + listOf( + SatelliteModel( + satelliteId = 22L, + name = "T", + bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0) + ) + ) + ) + `when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + `when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(cell)) + `when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap()) + doReturn(emptyMap>()).`when`(coverageSchemeClient).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval( + eqValue(listOf(22L)), + eqValue(intervalStart), + eqValue(intervalEnd), + anySurveyMap(), + isNull() + ) + + service.process( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + coverageSource = CoverageCalculationSource.COVERAGE_SCHEME + ) + + verify(coverageBatchClient).plannedModes(listOf(22L), intervalStart, intervalEnd) + verify(coverageSchemeClient).coverageModes( + anyListValue(), + eqValue(intervalStart), + eqValue(intervalEnd), + eqValue(listOf(22L)), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + verify(coverageBatchClient, never()).coverageModes( + anyListValue(), + anyLocalDateTime(), + anyLocalDateTime(), + anyListValue(), + anyOccupiedIntervals(), + isNull(), + isNull() + ) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanExecutionLockServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanExecutionLockServiceTest.kt new file mode 100644 index 0000000..80503d2 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanExecutionLockServiceTest.kt @@ -0,0 +1,43 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@SpringBootTest +class ComplexPlanExecutionLockServiceTest { + + @Autowired + private lateinit var complexPlanExecutionLockService: ComplexPlanExecutionLockService + + @Test + fun `same satellite cannot acquire execution lock twice before release`() { + val firstLock = complexPlanExecutionLockService.tryAcquire(listOf(22L)) + assertNotNull(firstLock) + + try { + val secondLock = complexPlanExecutionLockService.tryAcquire(listOf(22L)) + assertNull(secondLock) + } finally { + firstLock.close() + } + + val thirdLock = complexPlanExecutionLockService.tryAcquire(listOf(22L)) + assertNotNull(thirdLock) + thirdLock.close() + } + + @Test + fun `different satellites can acquire startup locks independently`() { + val firstLock = complexPlanExecutionLockService.tryAcquire(listOf(22L)) + val secondLock = complexPlanExecutionLockService.tryAcquire(listOf(23L)) + + assertNotNull(firstLock) + assertNotNull(secondLock) + + firstLock.close() + secondLock.close() + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt new file mode 100644 index 0000000..7ad27df --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt @@ -0,0 +1,362 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ComplexPlanRunOrchestrationServiceTest { + + @Test + fun `orchestration marks run failed when lock is unavailable`() { + val persistenceService = mock(ComplexPlanRunPersistenceService::class.java) + val queryService = mock(ComplexPlanRunQueryService::class.java) + val calculationService = mock(ComplexPlanCalculationService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val lockService = mock(ComplexPlanExecutionLockService::class.java) + + val orchestrationService = ComplexPlanRunOrchestrationService( + complexPlanRunPersistenceService = persistenceService, + complexPlanRunQueryService = queryService, + complexPlanCalculationService = calculationService, + satelliteService = satelliteService, + complexPlanExecutionLockService = lockService + ) + + val request = ComplexPlanProcessRequestDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteId = 22L + ) + val createdRun = ComplexPlanRunEntity( + id = 10L, + status = ComplexPlanRunStatus.CREATED, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd + ) + + doReturn(createdRun) + .`when`(persistenceService) + .createRun( + ComplexPlanRunCreateDTO( + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":null,\"countLat\":null,\"countLong\":null,\"coverageStrategy\":\"GREEDY\"}" + ) + ) + doReturn(null).`when`(lockService).tryAcquire(listOf(22L)) + + assertFailsWith { + orchestrationService.process(request, requestedBy = "tester") + } + + verify(persistenceService).markFailed( + 10L, + "Complex plan recalculation is already running for at least one requested satellite" + ) + verify(calculationService, never()).process( + 10L, + request.intervalStart, + request.intervalEnd, + listOf(22L), + null, + CoverageCalculationSource.SLOTS, + null, + null, + SlotCoverageStrategy.GREEDY + ) + } + + @Test + fun `orchestration marks run failed when overlapping RUNNING run exists`() { + val persistenceService = mock(ComplexPlanRunPersistenceService::class.java) + val queryService = mock(ComplexPlanRunQueryService::class.java) + val calculationService = mock(ComplexPlanCalculationService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val lockService = mock(ComplexPlanExecutionLockService::class.java) + val lockHandle = ComplexPlanExecutionLockService.ExecutionLockHandle {} + + val orchestrationService = ComplexPlanRunOrchestrationService( + complexPlanRunPersistenceService = persistenceService, + complexPlanRunQueryService = queryService, + complexPlanCalculationService = calculationService, + satelliteService = satelliteService, + complexPlanExecutionLockService = lockService + ) + + val request = ComplexPlanProcessRequestDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteId = 22L + ) + val createdRun = ComplexPlanRunEntity( + id = 10L, + status = ComplexPlanRunStatus.CREATED, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd + ) + + doReturn(createdRun) + .`when`(persistenceService) + .createRun( + ComplexPlanRunCreateDTO( + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":null,\"countLat\":null,\"countLong\":null,\"coverageStrategy\":\"GREEDY\"}" + ) + ) + doReturn(lockHandle).`when`(lockService).tryAcquire(listOf(22L)) + doReturn(listOf(99L)) + .`when`(persistenceService) + .findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L) + + assertFailsWith { + orchestrationService.process(request, requestedBy = "tester") + } + + verify(persistenceService).markFailed( + 10L, + "Complex plan recalculation is already running for overlapping interval, runIds=[99]" + ) + verify(calculationService, never()).process( + 10L, + request.intervalStart, + request.intervalEnd, + listOf(22L), + null, + CoverageCalculationSource.SLOTS, + null, + null, + SlotCoverageStrategy.GREEDY + ) + } + + @Test + fun `orchestration marks run running before releasing startup lock`() { + val persistenceService = mock(ComplexPlanRunPersistenceService::class.java) + val queryService = mock(ComplexPlanRunQueryService::class.java) + val calculationService = mock(ComplexPlanCalculationService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val lockService = mock(ComplexPlanExecutionLockService::class.java) + val lockHandle = mock(ComplexPlanExecutionLockService.ExecutionLockHandle::class.java) + + val orchestrationService = ComplexPlanRunOrchestrationService( + complexPlanRunPersistenceService = persistenceService, + complexPlanRunQueryService = queryService, + complexPlanCalculationService = calculationService, + satelliteService = satelliteService, + complexPlanExecutionLockService = lockService + ) + + val request = ComplexPlanProcessRequestDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteId = 22L + ) + val createdRun = ComplexPlanRunEntity( + id = 10L, + status = ComplexPlanRunStatus.CREATED, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd + ) + val runningRun = ComplexPlanRunEntity( + id = 10L, + status = ComplexPlanRunStatus.RUNNING, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd + ) + val response = ComplexPlanRunResponseDTO( + runId = 10L, + status = ComplexPlanRunStatus.COMPLETED, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), + startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), + finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + durationMs = 60_000, + errorMessage = null, + calculatedModesCount = 1, + bookedModesCount = 0, + complanModesCount = 1, + mixedModesCount = 0, + affectedSatellitesCount = 1, + processedCellsCount = 1, + bookedSlotLinksCount = 0, + snapshotIds = listOf(501L), + requestPayload = "{}" + ) + + doReturn(createdRun) + .`when`(persistenceService) + .createRun( + ComplexPlanRunCreateDTO( + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":null,\"countLat\":null,\"countLong\":null,\"coverageStrategy\":\"GREEDY\"}" + ) + ) + doReturn(lockHandle).`when`(lockService).tryAcquire(listOf(22L)) + doReturn(emptyList()) + .`when`(persistenceService) + .findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L) + doReturn(runningRun).`when`(persistenceService).markRunning(10L) + doReturn(ComplexPlanCalculationResult(calculatedModesCount = 1)) + .`when`(calculationService) + .process( + 10L, + request.intervalStart, + request.intervalEnd, + listOf(22L), + null, + CoverageCalculationSource.SLOTS, + null, + null, + SlotCoverageStrategy.GREEDY + ) + doReturn(createdRun) + .`when`(persistenceService) + .markCompleted(10L, ComplexPlanRunStatisticsDTO(calculatedModesCount = 1)) + doReturn(response).`when`(queryService).getRun(10L) + + val result = orchestrationService.process(request, requestedBy = "tester") + + val ordered = inOrder(persistenceService, lockHandle, calculationService) + ordered.verify(persistenceService) + .findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L) + ordered.verify(persistenceService).markRunning(10L) + ordered.verify(lockHandle).close() + ordered.verify(calculationService).process( + 10L, + request.intervalStart, + request.intervalEnd, + listOf(22L), + null, + CoverageCalculationSource.SLOTS, + null, + null, + SlotCoverageStrategy.GREEDY + ) + assertEquals(10L, result.runId) + } + + @Test + fun `orchestration passes requested coverage source to calculation`() { + val persistenceService = mock(ComplexPlanRunPersistenceService::class.java) + val queryService = mock(ComplexPlanRunQueryService::class.java) + val calculationService = mock(ComplexPlanCalculationService::class.java) + val satelliteService = mock(SatelliteService::class.java) + val lockService = mock(ComplexPlanExecutionLockService::class.java) + val lockHandle = mock(ComplexPlanExecutionLockService.ExecutionLockHandle::class.java) + + val orchestrationService = ComplexPlanRunOrchestrationService( + complexPlanRunPersistenceService = persistenceService, + complexPlanRunQueryService = queryService, + complexPlanCalculationService = calculationService, + satelliteService = satelliteService, + complexPlanExecutionLockService = lockService + ) + + val request = ComplexPlanProcessRequestDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteId = 22L, + coverageSource = CoverageCalculationSource.COVERAGE_SCHEME, + countLat = 2, + countLong = 3, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + val createdRun = ComplexPlanRunEntity( + id = 10L, + status = ComplexPlanRunStatus.CREATED, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd + ) + val response = ComplexPlanRunResponseDTO( + runId = 10L, + status = ComplexPlanRunStatus.COMPLETED, + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), + startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), + finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + durationMs = 60_000, + errorMessage = null, + calculatedModesCount = 0, + bookedModesCount = 0, + complanModesCount = 0, + mixedModesCount = 0, + affectedSatellitesCount = 0, + processedCellsCount = 0, + bookedSlotLinksCount = 0, + snapshotIds = emptyList(), + requestPayload = "{}" + ) + + doReturn(createdRun).`when`(persistenceService).createRun( + ComplexPlanRunCreateDTO( + intervalStart = request.intervalStart, + intervalEnd = request.intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":\"COVERAGE_SCHEME\",\"countLat\":2,\"countLong\":3,\"coverageStrategy\":\"CONTINUOUS\"}" + ) + ) + doReturn(lockHandle).`when`(lockService).tryAcquire(listOf(22L)) + doReturn(emptyList()) + .`when`(persistenceService) + .findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L) + doReturn(createdRun).`when`(persistenceService).markRunning(10L) + doReturn(ComplexPlanCalculationResult()).`when`(calculationService).process( + 10L, + request.intervalStart, + request.intervalEnd, + listOf(22L), + null, + CoverageCalculationSource.COVERAGE_SCHEME, + 2, + 3, + SlotCoverageStrategy.CONTINUOUS + ) + doReturn(createdRun).`when`(persistenceService).markCompleted(10L, ComplexPlanRunStatisticsDTO()) + doReturn(response).`when`(queryService).getRun(10L) + + orchestrationService.process(request, requestedBy = "tester") + + verify(calculationService).process( + 10L, + request.intervalStart, + request.intervalEnd, + listOf(22L), + null, + CoverageCalculationSource.COVERAGE_SCHEME, + 2, + 3, + SlotCoverageStrategy.CONTINUOUS + ) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunPersistenceServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunPersistenceServiceTest.kt new file mode 100644 index 0000000..798f989 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunPersistenceServiceTest.kt @@ -0,0 +1,334 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO +import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@SpringBootTest +class ComplexPlanRunPersistenceServiceTest { + + @Autowired + private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService + + @Autowired + private lateinit var complexPlanRunRepository: ComplexPlanRunRepository + + @Autowired + private lateinit var satelliteModeRepository: SatelliteModeRepository + + @Autowired + private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository + + @Autowired + private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository + + @Autowired + private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository + + @Autowired + private lateinit var complexPlanPersistenceService: ComplexPlanPersistenceService + + @Autowired + private lateinit var complexPlanRunQueryService: ComplexPlanRunQueryService + + @BeforeEach + fun setUp() { + satelliteModeBookedSlotRepository.deleteAll() + satelliteModeCellRepository.deleteAll() + satelliteModeRepository.deleteAll() + satelliteModeSnapshotRepository.deleteAll() + complexPlanRunRepository.deleteAll() + } + + @Test + fun `create run stores CREATED status and then moves to RUNNING`() { + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L, 23L), + requestedBy = "tester", + requestPayload = """{"k":"v"}""" + ) + ) + + assertEquals(ComplexPlanRunStatus.CREATED, run.status) + assertEquals(listOf(22L, 23L), run.satellites.map { it.satelliteId }.sorted()) + + val running = complexPlanRunPersistenceService.markRunning(run.id!!) + assertEquals(ComplexPlanRunStatus.RUNNING, running.status) + assertNotNull(running.startedAt) + } + + @Test + fun `completed run stores duration and statistics`() { + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(run.id!!) + Thread.sleep(5) + + val completed = complexPlanRunPersistenceService.markCompleted( + runId = run.id!!, + statistics = ComplexPlanRunStatisticsDTO( + calculatedModesCount = 3, + bookedModesCount = 1, + complanModesCount = 1, + mixedModesCount = 1, + affectedSatellitesCount = 1, + processedCellsCount = 2, + bookedSlotLinksCount = 4, + totalRequestsArea = 10.0, + coveredArea = 4.0, + coveredAreaPercent = 40.0 + ) + ) + + assertEquals(ComplexPlanRunStatus.COMPLETED, completed.status) + assertNotNull(completed.finishedAt) + assertNotNull(completed.durationMs) + assertTrue(completed.durationMs!! >= 0) + assertEquals(3, completed.calculatedModesCount) + assertEquals(1, completed.bookedModesCount) + assertEquals(1, completed.complanModesCount) + assertEquals(1, completed.mixedModesCount) + assertEquals(2, completed.processedCellsCount) + assertEquals(4, completed.bookedSlotLinksCount) + assertEquals(10.0, completed.totalRequestsArea) + assertEquals(4.0, completed.coveredArea) + assertEquals(40.0, completed.coveredAreaPercent) + } + + @Test + fun `getRun returns linked snapshot ids and active snapshot ids`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + + complexPlanPersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 123, + time = intervalStart.plusMinutes(5), + timeStop = intervalStart.plusMinutes(15), + roll = 3.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN + ) + ) + ), + complexPlanRunId = run.id + ) + + val response = complexPlanRunQueryService.getRun(run.id!!) + + assertEquals(1, response.snapshotIds.size) + } + + @Test + fun `failed run stores error and keeps audit trail`() { + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + requestedBy = null, + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(run.id!!) + + val failed = complexPlanRunPersistenceService.markFailed(run.id!!, "boom") + + assertEquals(ComplexPlanRunStatus.FAILED, failed.status) + assertEquals("boom", failed.errorMessage) + assertNotNull(failed.finishedAt) + assertNotNull(failed.durationMs) + } + + @Test + fun `shutdown marks CREATED and RUNNING runs as FAILED and leaves completed untouched`() { + val createdRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + val runningRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 13, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 14, 0), + satelliteIds = listOf(23L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(runningRun.id!!) + val completedRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 15, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 16, 0), + satelliteIds = listOf(24L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(completedRun.id!!) + complexPlanRunPersistenceService.markCompleted( + runId = completedRun.id!!, + statistics = ComplexPlanRunStatisticsDTO(calculatedModesCount = 1) + ) + + val affectedRuns = complexPlanRunPersistenceService.markActiveRunsFailedOnShutdown() + + assertEquals(2, affectedRuns) + + val reloadedCreated = complexPlanRunRepository.findById(createdRun.id!!).orElseThrow() + val reloadedRunning = complexPlanRunRepository.findById(runningRun.id!!).orElseThrow() + val reloadedCompleted = complexPlanRunRepository.findById(completedRun.id!!).orElseThrow() + + assertEquals(ComplexPlanRunStatus.FAILED, reloadedCreated.status) + assertEquals(ComplexPlanRunStatus.FAILED, reloadedRunning.status) + assertEquals(ComplexPlanRunStatus.COMPLETED, reloadedCompleted.status) + assertEquals( + ComplexPlanRunPersistenceService.APPLICATION_SHUTDOWN_ERROR_MESSAGE, + reloadedCreated.errorMessage + ) + assertEquals( + ComplexPlanRunPersistenceService.APPLICATION_SHUTDOWN_ERROR_MESSAGE, + reloadedRunning.errorMessage + ) + assertNotNull(reloadedCreated.finishedAt) + assertNotNull(reloadedRunning.finishedAt) + assertNotNull(reloadedRunning.durationMs) + } + + @Test + fun `findOverlappingRunningRunIds is interval aware`() { + val runningRun = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 0), + satelliteIds = listOf(10L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(runningRun.id!!) + + val nonOverlapping = complexPlanRunPersistenceService.findOverlappingRunningRunIds( + satelliteIds = listOf(10L), + intervalStart = LocalDateTime.of(2026, 3, 26, 12, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 13, 0), + excludedRunId = -1L + ) + val overlapping = complexPlanRunPersistenceService.findOverlappingRunningRunIds( + satelliteIds = listOf(10L), + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 30), + intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 30), + excludedRunId = -1L + ) + val otherSatellite = complexPlanRunPersistenceService.findOverlappingRunningRunIds( + satelliteIds = listOf(11L), + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 30), + intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 30), + excludedRunId = -1L + ) + + assertTrue(nonOverlapping.isEmpty()) + assertEquals(listOf(runningRun.id!!), overlapping) + assertTrue(otherSatellite.isEmpty()) + } + + @Test + fun `startup recovery marks stale RUNNING runs as FAILED`() { + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 0), + satelliteIds = listOf(10L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + complexPlanRunPersistenceService.markRunning(run.id!!) + + val affectedRuns = complexPlanRunPersistenceService.markRunningRunsFailedOnStartup() + + val reloaded = complexPlanRunRepository.findById(run.id!!).orElseThrow() + assertEquals(1, affectedRuns) + assertEquals(ComplexPlanRunStatus.FAILED, reloaded.status) + assertEquals(ComplexPlanRunPersistenceService.STARTUP_RECOVERY_ERROR_MESSAGE, reloaded.errorMessage) + assertNotNull(reloaded.finishedAt) + } + + @Test + fun `startup recovery is no-op when there are no RUNNING runs`() { + val affectedRuns = complexPlanRunPersistenceService.markRunningRunsFailedOnStartup() + assertEquals(0, affectedRuns) + } + + @Test + fun `run keeps link to created snapshots`() { + val intervalStart = LocalDateTime.of(2026, 3, 27, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + + complexPlanPersistenceService.replaceModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = emptyMap(), + complexPlanRunId = run.id + ) + + val reloaded = complexPlanRunPersistenceService.getRun(run.id!!) + assertEquals(1, reloaded.snapshots.size) + assertEquals(22L, reloaded.snapshots.single().satelliteId) + assertTrue(reloaded.snapshots.single().isActive) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunShutdownHandlerTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunShutdownHandlerTest.kt new file mode 100644 index 0000000..7ba6df8 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunShutdownHandlerTest.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify + +class ComplexPlanRunShutdownHandlerTest { + + @Test + fun `context close handler marks active runs as failed`() { + val persistenceService = mock(ComplexPlanRunPersistenceService::class.java) + val handler = ComplexPlanRunShutdownHandler(persistenceService) + + handler.onContextClosed() + + verify(persistenceService).markActiveRunsFailedOnShutdown() + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunStartupRecoveryTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunStartupRecoveryTest.kt new file mode 100644 index 0000000..93d6418 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunStartupRecoveryTest.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify + +class ComplexPlanRunStartupRecoveryTest { + + @Test + fun `startup recovery invokes persistence reconciliation`() { + val persistenceService = mock(ComplexPlanRunPersistenceService::class.java) + doReturn(2).`when`(persistenceService).markRunningRunsFailedOnStartup() + + val recovery = ComplexPlanRunStartupRecovery(persistenceService) + recovery.onApplicationReady() + + verify(persistenceService).markRunningRunsFailedOnStartup() + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClientTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClientTest.kt new file mode 100644 index 0000000..00e27da --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClientTest.kt @@ -0,0 +1,130 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +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.requests.slots.SlotCoverageBatchResponseDTO +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 tools.jackson.databind.ObjectMapper +import java.net.InetSocketAddress +import java.time.LocalDateTime +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoverageBatchClientTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `coverageModes sends sun and coverage strategy in batch request body`() { + var requestBody = "" + server = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/slots/poly-cover/batch") { exchange -> + requestBody = exchange.requestBody.bufferedReader().readText() + val responseBody = "[]" + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } + start() + } + + val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider + `when`(builderProvider.ifAvailable).thenReturn(WebClient.builder()) + + val client = CoverageBatchClient(builderProvider) + ReflectionTestUtils.setField(client, "url", "http://localhost:${server!!.address.port}") + + client.coverageModes( + targets = listOf(SlotCoverageTargetDTO(targetId = 101L, wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")), + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + occupiedIntervals = emptyList(), + sun = 12.5, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + + assertTrue(requestBody.contains("\"sun\":12.5")) + assertTrue(requestBody.contains("\"coverageStrategy\":\"CONTINUOUS\"")) + } + + @Test + fun `coverageModes falls back to GREEDY when CONTINUOUS batch request returns server error`() { + val requestBodies = mutableListOf() + val requestCounter = AtomicInteger() + server = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/slots/poly-cover/batch") { exchange -> + val requestBody = exchange.requestBody.bufferedReader().readText() + requestBodies += requestBody + val attempt = requestCounter.incrementAndGet() + if (attempt == 1) { + val responseBody = """{"error":"solver failed"}""" + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(500, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } else { + val responseBody = ObjectMapper().writeValueAsString( + listOf( + SlotCoverageBatchResponseDTO( + targetId = 101L, + slots = listOf( + SlotDTO( + cycle = 0, + satelliteId = 22, + tn = LocalDateTime.of(2026, 3, 26, 10, 0), + tk = LocalDateTime.of(2026, 3, 26, 10, 10), + roll = 10.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 501, + latitude = 10.0, + longitude = 20.0 + ) + ) + ) + ) + ) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } + } + start() + } + + val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider + `when`(builderProvider.ifAvailable).thenReturn(WebClient.builder()) + + val client = CoverageBatchClient(builderProvider) + ReflectionTestUtils.setField(client, "url", "http://localhost:${server!!.address.port}") + + val response = client.coverageModes( + targets = listOf(SlotCoverageTargetDTO(targetId = 101L, wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")), + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + occupiedIntervals = emptyList(), + sun = null, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + + assertEquals(2, requestBodies.size) + assertTrue(requestBodies[0].contains("\"coverageStrategy\":\"CONTINUOUS\"")) + assertTrue(requestBodies[1].contains("\"coverageStrategy\":\"GREEDY\"")) + assertEquals(1, response.size) + assertEquals(1, response.getValue(101L).size) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClientTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClientTest.kt new file mode 100644 index 0000000..1087807 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClientTest.kt @@ -0,0 +1,85 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +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.requests.CoverageCalculationSource +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO +import java.net.InetSocketAddress +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoverageSchemeCalculationClientTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `coverageModes calls coverage scheme service and maps response to slot model`() { + var requestBody = "" + server = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/coverage-schemes/calculate") { exchange -> + requestBody = exchange.requestBody.bufferedReader().readText() + val responseBody = """ + { + "targetPolygonWkt": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "observations": [], + "coverageScheme": [ + { + "satelliteId": 22, + "tn": "2026-03-26T10:05:00", + "tk": "2026-03-26T10:15:00", + "roll": 8.5, + "contour": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "revolution": 501, + "revolutionSign": "ASC", + "slotIds": [], + "latitude": 10.0, + "longitude": 20.0 + } + ] + } + """.trimIndent() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } + start() + } + + val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider + `when`(builderProvider.ifAvailable).thenReturn(WebClient.builder()) + + val client = CoverageSchemeCalculationClient(builderProvider) + ReflectionTestUtils.setField(client, "url", "http://localhost:${server!!.address.port}") + + val result = client.coverageModes( + targets = listOf(SlotCoverageTargetDTO(targetId = 101L, wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")), + intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0), + intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), + satelliteIds = listOf(22L), + occupiedIntervals = emptyList(), + sun = null + ) + + val slot = result.getValue(101L).single() + assertEquals(CoverageCalculationSource.COVERAGE_SCHEME, client.source) + assertTrue(requestBody.contains("\"polygonWkt\":\"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))\"")) + assertTrue(requestBody.contains("\"satelliteIds\":[22]")) + assertEquals(22L, slot.satelliteId) + assertEquals(8.5, slot.roll) + assertEquals(501L, slot.revolution) + assertEquals(10.0, slot.latitude) + assertEquals(20.0, slot.longitude) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/EarthGridServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/EarthGridServiceTest.kt new file mode 100644 index 0000000..6f54222 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/EarthGridServiceTest.kt @@ -0,0 +1,150 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.springframework.beans.factory.ObjectProvider +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.util.unit.DataSize +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_satellites_service.configuration.WebClientConfig +import java.net.InetSocketAddress +import java.net.URI +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class EarthGridServiceTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `cells calls v1 cells with requests and maps wrapper items`() { + val requestedUris = mutableListOf() + server = serverWithCellsResponse(requestedUris) + + val cells = earthGridService().cells().toList() + val cell = cells.single() + val request = cell.requests.single() + + assertEquals("/v1/cells/with-requests", requestedUris.single().path) + assertEquals("minImportance=0.001", requestedUris.single().query) + assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/with-requests" }) + assertEquals(1L, cell.id) + assertEquals(1L, cell.num) + assertEquals(55.5, cell.latitude) + assertEquals(37.5, cell.longitude) + assertEquals(10.0, cell.importance) + assertEquals("POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))", cell.contour) + assertEquals("00000000-0000-0000-0000-000000000001", request.requestId) + assertEquals(42.5, request.covPercent) + assertEquals(9.0, request.importance) + assertEquals("POLYGON ((30.1 50.1, 30.2 50.1, 30.2 50.2, 30.1 50.2, 30.1 50.1))", request.contour) + } + + @Test + fun `cells forwards countLat and countLong to v1 cells with requests`() { + val requestedUris = mutableListOf() + server = serverWithCellsResponse(requestedUris) + + earthGridService().cells(countLat = 12, countLong = 24).toList() + + assertEquals("/v1/cells/with-requests", requestedUris.single().path) + assertEquals("minImportance=0.001&countLat=12&countLong=24", requestedUris.single().query) + assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/with-requests" }) + } + + @Test + fun `cells supports response larger than default webclient buffer`() { + val itemCount = 4_000 + val contour = "POLYGON ((${(1..80).joinToString(",") { index -> "$index $index" }}))" + val responseBody = buildString { + append("""{"items":[""") + repeat(itemCount) { index -> + if (index > 0) { + append(",") + } + append( + """ + { + "cellNum": $index, + "latitude": 55.5, + "longitude": 37.5, + "importance": 10.0, + "contour": "$contour", + "requests": [] + } + """.trimIndent() + ) + } + append("]}") + } + val requestedUris = mutableListOf() + server = serverWithCellsResponse(requestedUris, responseBody) + + val cells = earthGridService { + WebClientConfig(DataSize.ofMegabytes(20)).webClientBuilder() + }.cells().toList() + + assertEquals(itemCount, cells.size) + assertEquals("/v1/cells/with-requests", requestedUris.single().path) + assertEquals("minImportance=0.001", requestedUris.single().query) + } + + private fun serverWithCellsResponse(requestedUris: MutableList): HttpServer = + serverWithCellsResponse( + requestedUris, + """ + { + "items": [ + { + "cellNum": 1, + "latitude": 55.5, + "longitude": 37.5, + "importance": 10.0, + "contour": "POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))", + "requests": [ + { + "requestId": "00000000-0000-0000-0000-000000000001", + "contour": "POLYGON ((30.1 50.1, 30.2 50.1, 30.2 50.2, 30.1 50.2, 30.1 50.1))", + "coveragePercent": 42.5, + "importance": 9.0 + } + ] + } + ] + } + """.trimIndent() + ) + + private fun serverWithCellsResponse( + requestedUris: MutableList, + responseBody: String + ): HttpServer = + HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/v1/cells/with-requests") { exchange -> + requestedUris += exchange.requestURI + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } + start() + } + + private fun earthGridService( + builderFactory: () -> WebClient.Builder = { WebClient.builder() } + ): EarthGridService { + val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider + `when`(builderProvider.ifAvailable).thenReturn(builderFactory()) + + val service = EarthGridService(builderProvider) + ReflectionTestUtils.setField(service, "url", "http://localhost:${server!!.address.port}") + return service + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedServiceTest.kt new file mode 100644 index 0000000..9dc490f --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedServiceTest.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunSatelliteRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository + +class SatelliteDeletedServiceTest { + + @Test + fun `deleteSatelliteData removes complex mission satellite data`() { + val snapshotRepository = mock(SatelliteModeSnapshotRepository::class.java) + val modeRepository = mock(SatelliteModeRepository::class.java) + val runSatelliteRepository = mock(ComplexPlanRunSatelliteRepository::class.java) + val service = SatelliteDeletedService(snapshotRepository, modeRepository, runSatelliteRepository) + + `when`(modeRepository.countBySatelliteId(56756L)).thenReturn(12L) + `when`(snapshotRepository.countBySatelliteId(56756L)).thenReturn(3L) + `when`(runSatelliteRepository.countBySatelliteId(56756L)).thenReturn(2L) + `when`(snapshotRepository.deleteAllBySatelliteId(56756L)).thenReturn(3L) + `when`(runSatelliteRepository.deleteAllBySatelliteId(56756L)).thenReturn(2L) + + val summary = service.deleteSatelliteData(56756L) + + assertEquals(56756L, summary.satelliteId) + assertEquals(3L, summary.snapshotsDeleted) + assertEquals(12L, summary.modesDeletedByCascade) + assertEquals(2L, summary.runLinksDeleted) + verify(snapshotRepository).deleteAllBySatelliteId(56756L) + verify(runSatelliteRepository).deleteAllBySatelliteId(56756L) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModePersistenceServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModePersistenceServiceTest.kt new file mode 100644 index 0000000..ec9ad1f --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModePersistenceServiceTest.kt @@ -0,0 +1,480 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean +import org.mockito.Mockito.doThrow +import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@SpringBootTest +class SatelliteModePersistenceServiceTest { + + @Autowired + private lateinit var satelliteModePersistenceService: SatelliteModePersistenceService + + @Autowired + private lateinit var satelliteModeQueryService: SatelliteModeQueryService + + @Autowired + private lateinit var satelliteModeRepository: SatelliteModeRepository + + @Autowired + private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository + + @Autowired + private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository + + @Autowired + private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository + + @Autowired + private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService + + @Autowired + private lateinit var complexPlanPersistenceService: ComplexPlanPersistenceService + + @Autowired + private lateinit var complexPlanRunRepository: ComplexPlanRunRepository + + @MockitoSpyBean + private lateinit var satelliteModeRepositorySpy: SatelliteModeRepository + + @BeforeEach + fun setUp() { + satelliteModeBookedSlotRepository.deleteAll() + satelliteModeCellRepository.deleteAll() + satelliteModeRepository.deleteAll() + satelliteModeSnapshotRepository.deleteAll() + complexPlanRunRepository.deleteAll() + } + + @Test + fun `deleteModesForInterval removes only intersecting satellite modes and relations`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L, 56756L), + intervalStart = intervalStart.minusHours(2), + intervalEnd = intervalEnd.plusHours(2), + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 100, + time = intervalStart.plusMinutes(10), + timeStop = intervalStart.plusMinutes(20), + roll = 5.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + source = SatelliteModeSource.SLOTS, + bookedSlotIds = listOf(1001L), + cellNums = listOf(42L) + ) + ), + 56756L to listOf( + SurveyMode( + revolution = 200, + time = intervalEnd.plusHours(1), + timeStop = intervalEnd.plusHours(2), + roll = 7.0, + contourWKT = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))", + source = SatelliteModeSource.COMPLAN, + bookedSlotIds = listOf(2001L), + cellNums = listOf(99L) + ) + ) + ) + ) + + satelliteModePersistenceService.deleteModesForInterval(listOf(22L), intervalStart, intervalEnd) + + val remaining = satelliteModeRepository.findAll() + assertEquals(1, remaining.size) + assertEquals(56756L, remaining.single().satelliteId) + assertEquals(1, satelliteModeSnapshotRepository.count()) + assertEquals(1, satelliteModeBookedSlotRepository.findAll().size) + assertEquals(1, satelliteModeCellRepository.findAll().size) + } + + @Test + fun `saveModesForInterval persists source booked slots and cell numbers`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 300, + time = intervalStart.plusMinutes(5), + timeStop = intervalStart.plusMinutes(25), + roll = 8.0, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + source = SatelliteModeSource.SLOTS, + bookedSlotIds = listOf(101L, 102L), + cellNums = listOf(42L, 43L) + ) + ) + ) + ) + + val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), intervalStart, intervalEnd) + assertEquals(1, modes.size) + assertEquals("SLOTS", modes.single().source) + assertEquals(listOf(101L, 102L), modes.single().bookedSlotIds) + assertEquals(listOf(42L, 43L), modes.single().cellNums) + assertEquals(-1L, modes.single().cellNum) + assertTrue(satelliteModeCellRepository.findAll().size == 2) + } + + @Test + fun `findModesByInterval reads active snapshot by satellite and filters returned modes by interval`() { + val snapshotStart = LocalDateTime.of(2026, 3, 26, 8, 0) + val snapshotEnd = LocalDateTime.of(2026, 3, 26, 14, 0) + val requestedStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val requestedEnd = LocalDateTime.of(2026, 3, 26, 11, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = snapshotStart, + intervalEnd = snapshotEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 301, + time = snapshotStart.plusMinutes(5), + timeStop = snapshotStart.plusMinutes(25), + roll = 7.0, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + source = SatelliteModeSource.SLOTS + ), + SurveyMode( + revolution = 302, + time = requestedStart.plusMinutes(10), + timeStop = requestedStart.plusMinutes(20), + roll = 8.0, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN + ) + ) + ) + ) + + val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), requestedStart, requestedEnd) + + assertEquals(1, modes.size) + assertEquals(302L, modes.single().revolution) + assertEquals("COMPLAN", modes.single().source) + } + + @Test + fun `saveModesForInterval stores complex plan run relation`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + val run = complexPlanRunPersistenceService.createRun( + ComplexPlanRunCreateDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + satelliteIds = listOf(22L), + requestedBy = "tester", + requestPayload = "{}" + ) + ) + + complexPlanPersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 400, + time = intervalStart.plusMinutes(5), + timeStop = intervalStart.plusMinutes(15), + roll = 8.0, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + source = SatelliteModeSource.COMPLAN, + cellNums = listOf(42L) + ) + ) + ), + complexPlanRunId = run.id + ) + + val linkedModes = satelliteModeRepository.findAllByComplexPlanRun_IdOrderByStartTimeAsc(run.id!!) + assertEquals(1, linkedModes.size) + assertEquals(run.id, linkedModes.single().complexPlanRun?.id) + assertEquals(run.id, linkedModes.single().snapshot?.complexPlanRun?.id) + } + + @Test + fun `replaceModesForInterval creates new active snapshot and keeps old history`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 100, + time = intervalStart.plusMinutes(1), + timeStop = intervalStart.plusMinutes(10), + roll = 5.0, + source = SatelliteModeSource.SLOTS, + bookedSlotIds = listOf(101L), + cellNums = listOf(42L) + ) + ) + ) + ) + + complexPlanPersistenceService.replaceModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 200, + time = intervalStart.plusMinutes(20), + timeStop = intervalStart.plusMinutes(30), + roll = 6.0, + source = SatelliteModeSource.COMPLAN, + bookedSlotIds = listOf(202L), + cellNums = listOf(43L, 44L) + ) + ) + ) + ) + + val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), intervalStart, intervalEnd) + assertEquals(1, modes.size) + assertEquals("COMPLAN", modes.single().source) + assertEquals(listOf(202L), modes.single().bookedSlotIds) + assertEquals(listOf(43L, 44L), modes.single().cellNums) + val snapshots = satelliteModeSnapshotRepository.findAll().sortedBy { it.id } + assertEquals(2, snapshots.size) + assertTrue(snapshots.last().isActive) + assertTrue(!snapshots.first().isActive) + } + + @Test + fun `saveModesForInterval keeps only one active snapshot per satellite across different intervals`() { + val firstStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val firstEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + val secondStart = LocalDateTime.of(2026, 3, 27, 10, 0) + val secondEnd = LocalDateTime.of(2026, 3, 27, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = firstStart, + intervalEnd = firstEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 401, + time = firstStart.plusMinutes(5), + timeStop = firstStart.plusMinutes(15), + roll = 4.0, + source = SatelliteModeSource.SLOTS + ) + ) + ) + ) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = secondStart, + intervalEnd = secondEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 402, + time = secondStart.plusMinutes(5), + timeStop = secondStart.plusMinutes(15), + roll = 5.0, + source = SatelliteModeSource.COMPLAN + ) + ) + ) + ) + + val snapshots = satelliteModeSnapshotRepository.findAll().filter { it.satelliteId == 22L }.sortedBy { it.createdAt } + assertEquals(2, snapshots.size) + assertEquals(1, snapshots.count { it.isActive }) + assertTrue(!snapshots.first().isActive) + assertTrue(snapshots.last().isActive) + } + + @Test + fun `replaceModesForInterval rolls back and keeps old result when save fails`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 300, + time = intervalStart.plusMinutes(1), + timeStop = intervalStart.plusMinutes(10), + roll = 5.0, + source = SatelliteModeSource.SLOTS, + bookedSlotIds = listOf(301L), + cellNums = listOf(42L) + ) + ) + ) + ) + + kotlin.test.assertFailsWith { + complexPlanPersistenceService.replaceModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 301, + time = intervalStart.plusMinutes(20), + timeStop = intervalStart.plusMinutes(30), + roll = 6.0, + source = SatelliteModeSource.COMPLAN, + bookedSlotIds = listOf(302L), + cellNums = listOf(43L) + ) + ) + ), + complexPlanRunId = Long.MAX_VALUE + ) + } + + val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), intervalStart, intervalEnd) + assertEquals(1, modes.size) + assertEquals("SLOTS", modes.single().source) + assertEquals(listOf(301L), modes.single().bookedSlotIds) + assertEquals(listOf(42L), modes.single().cellNums) + assertEquals(1, satelliteModeSnapshotRepository.count()) + assertTrue(satelliteModeSnapshotRepository.findAll().single().isActive) + } + + @Test + fun `saveModesForInterval creates stage4 snapshot and keeps it active`() { + val intervalStart = LocalDateTime.of(2026, 3, 28, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 28, 12, 0) + + val result = satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 601, + time = intervalStart.plusMinutes(10), + timeStop = intervalStart.plusMinutes(20), + roll = 5.0, + source = SatelliteModeSource.COMPLAN, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))" + ) + ) + ) + ) + + assertEquals(1, result.snapshotIds.size) + assertEquals(result.snapshotIds, result.activeSnapshotIds) + val snapshot = satelliteModeSnapshotRepository.findById(result.snapshotIds.single()).orElseThrow() + assertEquals(22L, snapshot.satelliteId) + assertTrue(snapshot.isActive) + assertEquals(snapshot.id, satelliteModeRepository.findAll().single().snapshot?.id) + } + + @Test + fun `saveModesForInterval persists MANUAL source`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 450, + time = intervalStart.plusMinutes(10), + timeStop = intervalStart.plusMinutes(20), + roll = 6.0, + source = SatelliteModeSource.MANUAL, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + cellNums = listOf(42L) + ) + ) + ) + ) + + val savedMode = satelliteModeRepository.findAll().single() + assertEquals(SatelliteModeSource.MANUAL, savedMode.source) + } + + @Test + fun `saveModesForInterval derives coordinates from contour for COMPLAN and MIXED`() { + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + satelliteModePersistenceService.saveModesForInterval( + satelliteIds = listOf(22L), + intervalStart = intervalStart, + intervalEnd = intervalEnd, + surveysBySatelliteId = mapOf( + 22L to listOf( + SurveyMode( + revolution = 500, + time = intervalStart.plusMinutes(5), + timeStop = intervalStart.plusMinutes(15), + roll = 4.0, + source = SatelliteModeSource.COMPLAN, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))", + cellNums = listOf(42L) + ), + SurveyMode( + revolution = 501, + time = intervalStart.plusMinutes(20), + timeStop = intervalStart.plusMinutes(30), + roll = 4.0, + source = SatelliteModeSource.MIXED, + contourWKT = "POLYGON ((10 10, 12 10, 12 11, 10 11, 10 10))", + cellNums = listOf(43L) + ) + ) + ) + ) + + val savedModes = satelliteModeRepository.findAll().sortedBy { it.startTime } + assertEquals(2, savedModes.size) + assertEquals(0.5, savedModes[0].lat) + assertEquals(1.0, savedModes[0].longitude) + assertEquals(10.5, savedModes[1].lat) + assertEquals(11.0, savedModes[1].longitude) + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeQueryServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeQueryServiceTest.kt new file mode 100644 index 0000000..d90d8b8 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteModeQueryServiceTest.kt @@ -0,0 +1,224 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeType +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository +import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SatelliteModeQueryServiceTest { + + @Test + fun `loadDayConstraintModes uses full day boundaries for single day interval`() { + val satelliteModeRepository = mock(SatelliteModeRepository::class.java) + val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java) + val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java) + val cellRepository = mock(SatelliteModeCellRepository::class.java) + val service = SatelliteModeQueryService( + satelliteModeRepository = satelliteModeRepository, + satelliteModeSnapshotRepository = satelliteModeSnapshotRepository, + satelliteModeBookedSlotRepository = bookedSlotRepository, + satelliteModeCellRepository = cellRepository, + satelliteModeMapper = SatelliteModeMapper(), + satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper() + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 15) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 45) + val dayMode = modeEntity( + id = 1L, + revolution = 501L, + startTime = LocalDateTime.of(2026, 3, 26, 12, 0), + endTime = LocalDateTime.of(2026, 3, 26, 12, 10) + ) + val sameRevolutionOutsideDayWindow = modeEntity( + id = 2L, + revolution = 501L, + startTime = LocalDateTime.of(2026, 3, 25, 23, 40), + endTime = LocalDateTime.of(2026, 3, 25, 23, 50) + ) + + doReturn(listOf(dayMode)).`when`(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + listOf(22L), + LocalDateTime.of(2026, 3, 26, 0, 0), + LocalDateTime.of(2026, 3, 27, 0, 0) + ) + doReturn(emptyList()) + .`when`(bookedSlotRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList()) + doReturn(emptyList()) + .`when`(cellRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList()) + + val loaded = service.loadDayConstraintModes(listOf(22L), intervalStart, intervalEnd) + + assertEquals(1, loaded.getValue(22L).size) + verify(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + listOf(22L), + LocalDateTime.of(2026, 3, 26, 0, 0), + LocalDateTime.of(2026, 3, 27, 0, 0) + ) + } + + @Test + fun `loadDayConstraintModes covers all touched days when interval crosses midnight`() { + val satelliteModeRepository = mock(SatelliteModeRepository::class.java) + val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java) + val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java) + val cellRepository = mock(SatelliteModeCellRepository::class.java) + val service = SatelliteModeQueryService( + satelliteModeRepository = satelliteModeRepository, + satelliteModeSnapshotRepository = satelliteModeSnapshotRepository, + satelliteModeBookedSlotRepository = bookedSlotRepository, + satelliteModeCellRepository = cellRepository, + satelliteModeMapper = SatelliteModeMapper(), + satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper() + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 23, 0) + val intervalEnd = LocalDateTime.of(2026, 3, 27, 1, 0) + + doReturn(emptyList()).`when`(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + listOf(22L), + LocalDateTime.of(2026, 3, 26, 0, 0), + LocalDateTime.of(2026, 3, 28, 0, 0) + ) + + service.loadDayConstraintModes(listOf(22L), intervalStart, intervalEnd) + + verify(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + listOf(22L), + LocalDateTime.of(2026, 3, 26, 0, 0), + LocalDateTime.of(2026, 3, 28, 0, 0) + ) + } + + @Test + fun `loadRevolutionConstraintModes loads same satellite revolution context outside day window`() { + val satelliteModeRepository = mock(SatelliteModeRepository::class.java) + val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java) + val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java) + val cellRepository = mock(SatelliteModeCellRepository::class.java) + val service = SatelliteModeQueryService( + satelliteModeRepository = satelliteModeRepository, + satelliteModeSnapshotRepository = satelliteModeSnapshotRepository, + satelliteModeBookedSlotRepository = bookedSlotRepository, + satelliteModeCellRepository = cellRepository, + satelliteModeMapper = SatelliteModeMapper(), + satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper() + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 15) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 45) + val dayMode = modeEntity( + id = 1L, + revolution = 501L, + startTime = LocalDateTime.of(2026, 3, 26, 12, 0), + endTime = LocalDateTime.of(2026, 3, 26, 12, 10) + ) + val sameRevolutionOutsideDayWindow = modeEntity( + id = 2L, + revolution = 501L, + startTime = LocalDateTime.of(2026, 3, 25, 23, 40), + endTime = LocalDateTime.of(2026, 3, 25, 23, 50) + ) + + doReturn(listOf(dayMode)).`when`(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + listOf(22L), + LocalDateTime.of(2026, 3, 26, 0, 0), + LocalDateTime.of(2026, 3, 27, 0, 0) + ) + doReturn(listOf(dayMode, sameRevolutionOutsideDayWindow)).`when`(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(22L, setOf(501L)) + doReturn(emptyList()) + .`when`(bookedSlotRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList()) + doReturn(emptyList()) + .`when`(cellRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList()) + + val loaded = service.loadRevolutionConstraintModes(listOf(22L), intervalStart, intervalEnd) + + assertEquals(2, loaded.getValue(22L).size) + assertTrue(loaded.getValue(22L).any { it.time == sameRevolutionOutsideDayWindow.startTime }) + } + + @Test + fun `loadConstraintModes returns merged deduplicated context`() { + val satelliteModeRepository = mock(SatelliteModeRepository::class.java) + val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java) + val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java) + val cellRepository = mock(SatelliteModeCellRepository::class.java) + val service = SatelliteModeQueryService( + satelliteModeRepository = satelliteModeRepository, + satelliteModeSnapshotRepository = satelliteModeSnapshotRepository, + satelliteModeBookedSlotRepository = bookedSlotRepository, + satelliteModeCellRepository = cellRepository, + satelliteModeMapper = SatelliteModeMapper(), + satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper() + ) + + val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 15) + val intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 45) + val dayMode = modeEntity( + id = 1L, + revolution = 501L, + startTime = LocalDateTime.of(2026, 3, 26, 12, 0), + endTime = LocalDateTime.of(2026, 3, 26, 12, 10) + ) + val sameRevolutionOutsideDayWindow = modeEntity( + id = 2L, + revolution = 501L, + startTime = LocalDateTime.of(2026, 3, 25, 23, 40), + endTime = LocalDateTime.of(2026, 3, 25, 23, 50) + ) + + doReturn(listOf(dayMode)).`when`(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc( + listOf(22L), + LocalDateTime.of(2026, 3, 26, 0, 0), + LocalDateTime.of(2026, 3, 27, 0, 0) + ) + doReturn(listOf(dayMode, sameRevolutionOutsideDayWindow)).`when`(satelliteModeRepository) + .findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(22L, setOf(501L)) + doReturn(emptyList()) + .`when`(bookedSlotRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList()) + doReturn(emptyList()) + .`when`(cellRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList()) + + val loaded = service.loadConstraintModes(listOf(22L), intervalStart, intervalEnd) + + assertEquals(2, loaded.getValue(22L).size) + assertEquals( + listOf(sameRevolutionOutsideDayWindow.startTime, dayMode.startTime), + loaded.getValue(22L).map { it.time } + ) + } + + private fun modeEntity( + id: Long, + revolution: Long, + startTime: LocalDateTime, + endTime: LocalDateTime + ) = SatelliteModeEntity( + id = id, + satelliteId = 22L, + source = SatelliteModeSource.COMPLAN, + type = SatelliteModeType.SURVEY, + startTime = startTime, + endTime = endTime, + revolution = revolution, + duration = java.time.Duration.between(startTime, endTime).seconds.toDouble(), + roll = 3.0 + ) +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteServiceTest.kt new file mode 100644 index 0000000..5bc5f42 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteServiceTest.kt @@ -0,0 +1,75 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DailyDurationDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.RevolutionDurationDTO +import java.time.LocalDate +import java.time.LocalDateTime +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class SatelliteServiceTest { + + @Test + fun `mission statistics groups durations by revolution`() { + val service = SatelliteService(testSatelliteCatalogClient()) + val satellite = service.satellites.first { it.satelliteId == 22L } + satellite.longMission.surveys.clear() + + satellite.longMission.surveys.add( + SurveyMode( + revolution = 101, + time = LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 10, 5), + duration = 300.0 + ) + ) + satellite.longMission.surveys.add( + SurveyMode( + revolution = 101, + time = LocalDateTime.of(2026, 3, 26, 10, 10), + timeStop = LocalDateTime.of(2026, 3, 26, 10, 12), + duration = 120.0 + ) + ) + satellite.longMission.surveys.add( + SurveyMode( + revolution = 102, + time = LocalDateTime.of(2026, 3, 26, 11, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 11, 3), + duration = 180.0, + contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))" + ) + ) + satellite.longMission.surveys.add( + SurveyMode( + revolution = 103, + time = LocalDateTime.of(2026, 3, 26, 23, 59), + timeStop = LocalDateTime.of(2026, 3, 27, 0, 1), + duration = 120.0, + contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) + ) + + val stats = service.missionStatistics(22L) + + assertEquals(4, stats.modesCount) + assertEquals(3.0, stats.totalContourArea) + assertContentEquals( + listOf( + RevolutionDurationDTO(revolution = 101, totalDuration = 420.0), + RevolutionDurationDTO(revolution = 102, totalDuration = 180.0), + RevolutionDurationDTO(revolution = 103, totalDuration = 120.0) + ), + stats.revolutionDurations + ) + assertContentEquals( + listOf( + DailyDurationDTO(date = LocalDate.of(2026, 3, 26), totalDuration = 660.0), + DailyDurationDTO(date = LocalDate.of(2026, 3, 27), totalDuration = 60.0) + ), + stats.dailyDurations + ) + } +} 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 new file mode 100644 index 0000000..a2dfaa5 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt @@ -0,0 +1,400 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource +import space.nstart.pcp.pcp_satellites_service.model.BLS +import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel +import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode +import java.time.Duration +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SurveyPlacementServiceTest { + + private val service = SurveyPlacementService() + + @Test + fun `candidate fits without trimming`() { + val surveys = mutableListOf() + val satellite = satellite() + + val decision = service.tryPlaceSurvey(surveys, survey(start = 0, end = 40), satellite) + + assertTrue(decision.accepted) + assertFalse(decision.trimmed) + assertEquals(1, surveys.size) + assertEquals(40.0, surveys.single().duration) + } + + @Test + fun `candidate exceeds daily max duration and is trimmed then accepted`() { + val surveys = mutableListOf(survey(start = 600, end = 670, revolution = 2, roll = 20.0)) + val satellite = satellite(dailyMaxDuration = 100.0) + + val decision = service.tryPlaceSurvey(surveys, survey(start = 0, end = 50), satellite) + + assertTrue(decision.accepted) + assertTrue(decision.trimmed) + assertEquals(2, surveys.size) + assertEquals(30.0, decision.adjustedCandidate?.duration) + } + + @Test + fun `candidate exceeds daily max duration and is rejected below duration min`() { + val surveys = mutableListOf(survey(start = 600, end = 695, revolution = 2, roll = 20.0)) + val satellite = satellite(dailyMaxDuration = 100.0) + + val decision = service.tryPlaceSurvey(surveys, survey(start = 0, end = 20), satellite) + + assertFalse(decision.accepted) + assertEquals("daily_max_duration", decision.rejectionReason) + assertEquals(1, surveys.size) + } + + @Test + fun `candidate exceeds revolution max duration and is trimmed then accepted`() { + val surveys = mutableListOf(survey(start = 600, end = 670, revolution = 5, roll = 20.0)) + val satellite = satellite(revolutionMaxDuration = 100.0) + + val decision = service.tryPlaceSurvey( + surveys, + survey(start = 0, end = 50, revolution = 5), + satellite + ) + + assertTrue(decision.accepted) + assertTrue(decision.trimmed) + assertEquals(30.0, decision.adjustedCandidate?.duration) + } + + @Test + fun `candidate exceeds merged chain duration max and is trimmed then accepted`() { + val surveys = mutableListOf(survey(start = 0, end = 80, roll = 10.0)) + val satellite = satellite(durationMax = 100.0, mmi = 10.0) + + val decision = service.tryPlaceSurvey( + surveys, + survey(start = 50, end = 140, roll = 10.0), + satellite + ) + + assertTrue(decision.accepted) + assertTrue(decision.trimmed) + assertEquals(1, surveys.size) + assertEquals(0, secondsFromBase(surveys.single().time)) + assertEquals(100, secondsFromBase(surveys.single().timeStop)) + } + + @Test + fun `merge allowed when time overlaps and geometry intersects`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + roll = 10.0, + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ) + ) + val satellite = satellite() + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 20, + end = 70, + roll = 10.0, + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(1, surveys.size) + assertTrue(surveys.single().contourWKT.startsWith("POLYGON")) + assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON")) + } + + @Test + fun `different roll conflict resolved by trimming`() { + val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0)) + val satellite = satellite(mmi = 10.0) + + val decision = service.tryPlaceSurvey( + surveys, + survey(start = 0, end = 70, roll = 10.0), + satellite + ) + + assertTrue(decision.accepted) + assertTrue(decision.trimmed) + assertEquals(40.0, decision.adjustedCandidate?.duration) + } + + @Test + fun `different roll conflict that cannot be resolved is rejected`() { + val surveys = mutableListOf(survey(start = 5, end = 40, roll = 20.0)) + val satellite = satellite(mmi = 10.0) + + val decision = service.tryPlaceSurvey( + surveys, + survey(start = 0, end = 30, roll = 10.0), + satellite + ) + + assertFalse(decision.accepted) + assertEquals("different_roll_conflict", decision.rejectionReason) + assertEquals(1, surveys.size) + } + + @Test + fun `epsilon based roll comparison treats nearly equal rolls as same chain`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + roll = 10.0, + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ) + ) + val satellite = satellite() + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 20, + end = 70, + roll = 10.005, + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(1, surveys.size) + assertEquals(70, secondsFromBase(surveys.single().timeStop)) + assertNotNull(decision.appliedSurvey) + } + + @Test + fun `merge forbidden when time overlaps but geometry does not intersect`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) + ) + val satellite = satellite() + + val logs = captureLogs { + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 20, + end = 70, + contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))" + ), + satellite + ) + + assertTrue(decision.accepted) + } + + assertEquals(2, surveys.size) + assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") }) + } + + @Test + fun `merge forbidden when contours only touch by boundary`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) + ) + val satellite = satellite() + + val logs = captureLogs { + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 20, + end = 70, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ), + satellite + ) + + assertTrue(decision.accepted) + } + + assertEquals(2, surveys.size) + assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") }) + } + + @Test + fun `merge forbidden when intervals do not overlap but gap is within mmi`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ) + ) + val satellite = satellite(mmi = 10.0) + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 45, + end = 70, + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(2, surveys.size) + } + + @Test + fun `merge allowed when intervals are adjacent and contours are continuous`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) + ) + val satellite = satellite() + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 40, + end = 70, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(1, surveys.size) + assertEquals(0, secondsFromBase(surveys.single().time)) + assertEquals(70, secondsFromBase(surveys.single().timeStop)) + } + + @Test + fun `defensive fallback skips merge when union returns multipolygon`() { + val logs = captureLogs { + val method = service.javaClass.getDeclaredMethod( + "unionContoursIfPolygon", + String::class.java, + String::class.java, + Long::class.javaPrimitiveType, + SurveyMode::class.java, + SurveyMode::class.java + ) + method.isAccessible = true + + val result = method.invoke( + service, + "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", + 22L, + survey(start = 20, end = 70, contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"), + survey(start = 0, end = 40, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))") + ) + + assertEquals(null, result) + } + + assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") }) + } + + @Test + fun `no multipolygon persisted for merged route`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 40, + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ) + ) + val satellite = satellite() + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 20, + end = 70, + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(1, surveys.size) + assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON")) + } + + private fun satellite( + durationMin: Double = 10.0, + durationMax: Double = 300.0, + mmi: Double = 10.0, + dailyMaxDuration: Double = 1_000.0, + revolutionMaxDuration: Double = 1_000.0 + ) = SatelliteModel( + satelliteId = 22, + name = "T", + bls = BLS( + durationMin = durationMin, + durationMax = durationMax, + mmi = mmi, + dailyMaxDuration = dailyMaxDuration, + revolutionMaxDuration = revolutionMaxDuration + ) + ) + + private fun survey( + start: Long, + end: Long, + revolution: Long = 1, + roll: Double = 10.0, + source: SatelliteModeSource = SatelliteModeSource.COMPLAN, + contour: String = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) = SurveyMode( + revolution = revolution, + time = baseTime().plusSeconds(start), + timeStop = baseTime().plusSeconds(end), + roll = roll, + duration = Duration.between(baseTime().plusSeconds(start), baseTime().plusSeconds(end)).seconds.toDouble(), + contourWKT = contour, + source = source + ) + + private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0) + + private fun secondsFromBase(value: LocalDateTime): Long = Duration.between(baseTime(), value).seconds + + private fun captureLogs(block: () -> Unit): List { + val logger = LoggerFactory.getLogger(SurveyPlacementService::class.java) as Logger + val appender = ListAppender() + appender.start() + logger.addAppender(appender) + try { + block() + return appender.list.toList() + } finally { + logger.detachAppender(appender) + } + } +} diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/TestSatelliteCatalogClient.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/TestSatelliteCatalogClient.kt new file mode 100644 index 0000000..5f80b28 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/TestSatelliteCatalogClient.kt @@ -0,0 +1,45 @@ +package space.nstart.pcp.pcp_satellites_service.service + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO + +fun testSatelliteCatalogClient(): SatelliteCatalogClient = object : SatelliteCatalogClient { + override fun allSatellites(): List = listOf( + satelliteDto(10L, "Reflexio", SatelliteVisualizationDTO(125, 125, 78)), + satelliteDto(11L, "Reflexio", SatelliteVisualizationDTO(125, 125, 78)), + satelliteDto(22L, "Emissio", SatelliteVisualizationDTO(0, 125, 0)), + satelliteDto(56756L, "KONDOR-FKA NO. 1", SatelliteVisualizationDTO(255, 0, 0), scanTle = true), + satelliteDto(62138L, "KONDOR-FKA NO. 2", SatelliteVisualizationDTO(255, 125, 0), scanTle = true) + ) +} + +private fun satelliteDto( + id: Long, + name: String, + visualization: SatelliteVisualizationDTO, + scanTle: Boolean = false +) = SatelliteDTO( + id = id, + noradId = if (scanTle) id else null, + code = "TEST-$id", + name = name, + typeCode = name.uppercase().replace(' ', '_'), + active = true, + scanTle = scanTle, + visualization = visualization, + observationProfile = SatelliteObservationProfileDTO() +) + +@Configuration +@Profile("test") +class TestSatelliteCatalogClientConfiguration { + + @Bean + @Primary + fun satelliteCatalogClient(): SatelliteCatalogClient = testSatelliteCatalogClient() +} diff --git a/services/pcp-complex-mission-service/src/test/resources/application.yaml b/services/pcp-complex-mission-service/src/test/resources/application.yaml new file mode 100644 index 0000000..2254ca5 --- /dev/null +++ b/services/pcp-complex-mission-service/src/test/resources/application.yaml @@ -0,0 +1,26 @@ +spring: + cloud: + config: + enabled: false + datasource: + url: jdbc:h2:mem:pcp_satellites;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + flyway: + enabled: false + +settings: + calculation-interval: 3 + ballistics-service: http://localhost:7003 + earth-grid-service: http://localhost:7005 + complex-mission-service: http://localhost:7002 + stations-service: http://localhost:7009 + slots-service: http://localhost:7006 + coverage-scheme-service: http://localhost:7011 diff --git a/services/pcp-coverage-scheme-service/Dockerfile b/services/pcp-coverage-scheme-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-coverage-scheme-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-coverage-scheme-service/build.gradle.kts b/services/pcp-coverage-scheme-service/build.gradle.kts new file mode 100644 index 0000000..d3aaac8 --- /dev/null +++ b/services/pcp-coverage-scheme-service/build.gradle.kts @@ -0,0 +1,54 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +version = "1.0.0" + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:ballistics-lib")) + implementation(project(":libs:pcp-types-lib")) + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.locationtech.jts:jts-core:1.19.0") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType { + useJUnitPlatform() +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/PcpCoverageSchemeServiceApplication.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/PcpCoverageSchemeServiceApplication.kt new file mode 100644 index 0000000..4e567c9 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/PcpCoverageSchemeServiceApplication.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_coverage_scheme_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication + +@SpringBootApplication +@ConfigurationPropertiesScan +class PcpCoverageSchemeServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/BallisticsClient.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/BallisticsClient.kt new file mode 100644 index 0000000..201d82a --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/BallisticsClient.kt @@ -0,0 +1,131 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.client + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.Duration +import java.time.LocalDateTime + +@Service +class BallisticsClient( + webClientBuilderProvider: ObjectProvider, + private val properties: CoverageSchemeProperties +) : CoverageBallisticsClient { + + + private val logger = LoggerFactory.getLogger(this::class.java) + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + private fun client(): WebClient = + webClientBuilder + .baseUrl(properties.ballisticsServiceUrl) + .clientConnector( + ReactorClientHttpConnector( + HttpClient.create().responseTimeout(Duration.ofSeconds(properties.webClientResponseTimeoutSeconds)) + ) + ) + .codecs { codecs -> codecs.defaultCodecs().maxInMemorySize(properties.webClientInMemorySizeMb * 1024 * 1024) } + .build() + + override fun mplSquare(req: ObjViewRequestDTO): List = + client() + .post() + .uri("/api/obj-view/mpl-square") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux(SquareViewParamDTO::class.java) + .collectList() + .block() + ?: emptyList() + + override fun mplPoint(req: ObjViewRequestDTO): List = + client() + .post() + .uri("/api/obj-view/mpl-point") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux(PointViewParamDTO::class.java) + .collectList() + .doOnError { error -> + logger.warn( + "Failed to fetch mpl point from ballistics-service: satellites={}, interval=[{} - {}], error={}", + req.satellites, + req.timeStart, + req.timeStop, + error.message + ) + } + .onErrorMap { error -> + CustomErrorException("Не удалось получить mpl-point из баллистики: ${error.message}") + } + .block() + ?: emptyList() + + override fun flightLine(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime): List = + client() + .get() + .uri { uriBuilder -> + uriBuilder + .path("/api/satellites/{satelliteId}/flight-line") + .queryParam("time_start", timeStart) + .queryParam("time_stop", timeStop) + .build(satelliteId) + } + .retrieve() + .bodyToFlux(FlightLineDTO::class.java) + .collectList() + .doOnError { error -> + logger.warn( + "Failed to fetch flight line from ballistics-service: satelliteId={}, interval=[{} - {}], error={}", + satelliteId, + timeStart, + timeStop, + error.message + ) + } + .onErrorMap { error -> + CustomErrorException("Не удалось получить трассу полета для КА $satelliteId: ${error.message}") + } + .block() + ?: emptyList() + + override fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List = + client() + .post() + .uri("/api/satellites/{satelliteId}/extract-time", satelliteId) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToFlux(OrbPointDTO::class.java) + .collectList() + .doOnError { error -> + logger.warn( + "Failed to fetch orbital points from ballistics-service: satelliteId={}, interval=[{} - {}], stepMs={}, error={}", + satelliteId, + request.time, + request.timeStop, + request.stepMs, + error.message + ) + } + .onErrorMap { error -> + CustomErrorException("Не удалось получить точки орбиты для КА $satelliteId: ${error.message}") + } + .block() + ?: emptyList() +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/ComplexMissionService.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/ComplexMissionService.kt new file mode 100644 index 0000000..8e6f427 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/ComplexMissionService.kt @@ -0,0 +1,60 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.client + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import java.time.Duration + +@Service +class ComplexMissionService( + webClientBuilderProvider: ObjectProvider, + private val properties: CoverageSchemeProperties +) : CoverageSatelliteClient { + + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + private fun client(): WebClient = + webClientBuilder + .baseUrl(properties.satelliteCatalogServiceUrl) + .clientConnector( + ReactorClientHttpConnector( + HttpClient.create().responseTimeout(Duration.ofSeconds(properties.webClientResponseTimeoutSeconds)) + ) + ) + .codecs { codecs -> codecs.defaultCodecs().maxInMemorySize(properties.webClientInMemorySizeMb * 1024 * 1024) } + .build() + + override fun satellite(satelliteId: Long): SatelliteInfoDTO = + client() + .get() + .uri("/api/satellites/{satelliteId}", satelliteId) + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .map { satellite -> + SatelliteInfoDTO( + noradId = satellite.id, + name = satellite.name, + red = satellite.visualization.red, + green = satellite.visualization.green, + blue = satellite.visualization.blue, + captureAngle = satellite.observationProfile?.captureAngle ?: 1.5, + scanTLE = satellite.scanTle, + maxSurveyDurationSeconds = satellite.observationProfile?.durationMaxSeconds, + mmiSeconds = satellite.observationProfile?.mmiSeconds, + code = satellite.code, + typeCode = satellite.typeCode, + active = satellite.active + ) + } + .onErrorMap { error -> + CustomErrorException("Не удалось получить параметры КА $satelliteId: ${error.message}") + } + .block() + ?: throw CustomErrorException("Не удалось получить параметры КА $satelliteId: пустой ответ satellite-catalog-service") +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/CoverageBallisticsClient.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/CoverageBallisticsClient.kt new file mode 100644 index 0000000..cdc3efa --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/CoverageBallisticsClient.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.client + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.LocalDateTime + +interface CoverageBallisticsClient { + fun mplSquare(req: ObjViewRequestDTO): List + fun mplPoint(req: ObjViewRequestDTO): List + fun flightLine(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime): List + fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/CoverageSatelliteClient.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/CoverageSatelliteClient.kt new file mode 100644 index 0000000..7697eee --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/client/CoverageSatelliteClient.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.client + +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO + +interface CoverageSatelliteClient { + fun satellite(satelliteId: Long): SatelliteInfoDTO +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CoverageSchemeProperties.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CoverageSchemeProperties.kt new file mode 100644 index 0000000..1d6380c --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CoverageSchemeProperties.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.configuration + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("coverage-scheme") +data class CoverageSchemeProperties( + val ballisticsServiceUrl: String = "http://localhost:7003", + val complexMissionServiceUrl: String = "http://localhost:7002", + val satelliteCatalogServiceUrl: String = complexMissionServiceUrl, + val defaultRollStepDegrees: Double = 5.0, + val defaultMinimumTechnologyOverlap: Double = 0.05, + val defaultAllowPartialCoverage: Boolean = true, + val boundaryTouchBuffer: Double = 0.05, + val continuityGapTolerance: Double = 0.000001, + val defaultOrientationToleranceDegrees: Double = 15.0, + val maxRollSamplesPerWindow: Int = 7, + val flightLinePaddingSeconds: Long = 120, + val webClientResponseTimeoutSeconds: Long = 30, + val webClientInMemorySizeMb: Int = 20, + val fallbackSwathWidthDegrees: Double = 1.0, + val fallbackShiftPerRollDegree: Double = 0.02, + val defaultMinimumTimeBetweenModesSeconds: Long = 0 +) diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CustomErrorException.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CustomErrorException.kt new file mode 100644 index 0000000..27d1735 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CustomErrorException.kt @@ -0,0 +1,3 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.configuration + +class CustomErrorException(message: String) : RuntimeException(message) diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CustomValidationException.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CustomValidationException.kt new file mode 100644 index 0000000..0d609c1 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/CustomValidationException.kt @@ -0,0 +1,3 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.configuration + +class CustomValidationException(message: String) : RuntimeException(message) diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/GlobalExceptionHandler.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..e43bf44 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.configuration + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> = + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (ex.message ?: "validation error"))) + + @ExceptionHandler(CustomErrorException::class) + fun handleCustomError(ex: CustomErrorException): ResponseEntity> = + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (ex.message ?: "error"))) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleMethodArgumentNotValid(ex: MethodArgumentNotValidException): ResponseEntity> { + val message = ex.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "validation error" + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to message)) + } +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/OpenApiConfig.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/OpenApiConfig.kt new file mode 100644 index 0000000..102b7ea --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/configuration/OpenApiConfig.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.configuration + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * Базовая OpenAPI-конфигурация для REST API сервиса схем покрытия. + */ +@Configuration +class OpenApiConfig { + @Bean + fun coverageSchemeOpenApi(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("PCP Coverage Scheme Service API") + .description("REST API сервиса расчета схем беспрерывного покрытия PCP.") + .version("v1"), + ) + } +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/controller/CoverageSchemeController.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/controller/CoverageSchemeController.kt new file mode 100644 index 0000000..7e85f83 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/controller/CoverageSchemeController.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.controller + +import jakarta.validation.Valid +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +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_coverage_scheme_service.service.CoverageSchemeCalculationService +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO + +@RestController +@RequestMapping("/api/coverage-schemes") +@Tag(name = "Coverage Schemes", description = "Расчет схем беспрерывного покрытия полигона контурами режимов съемки") +class CoverageSchemeController( + private val coverageSchemeCalculationService: CoverageSchemeCalculationService +) { + + @PostMapping("/calculate") + @Operation(summary = "Рассчитать схему беспрерывного покрытия полигона") + fun calculate( + @Valid @RequestBody request: CoverageSchemeCalculateRequestDTO + ) = coverageSchemeCalculationService.calculate(request) +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageCandidateMode.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageCandidateMode.kt new file mode 100644 index 0000000..7d7bff1 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageCandidateMode.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.model + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.Point +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign + +import java.time.LocalDateTime + +data class CoverageCandidateMode( + val window: CoverageObservationWindow, + val rollDegrees: Double, + val contour: Geometry, + val targetCoverage: Geometry, + val anchor: Point, + val centerShift: Double, + val generationDirectionDegrees: Double, + val startBoundary: String, + val sequenceSeed: Int +) { + + val timeStart: LocalDateTime get() = window.timeStart + val timeEnd: LocalDateTime get() = window.timeEnd + val branch: RevolutionSign get() = window.branch +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageObservationWindow.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageObservationWindow.kt new file mode 100644 index 0000000..bcd0005 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageObservationWindow.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.model + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +data class CoverageObservationWindow( + val satelliteId: Long, + val timeStart: LocalDateTime, + val timeEnd: LocalDateTime, + val branch: RevolutionSign, + val rollMinDegrees: Double, + val rollMaxDegrees: Double +) diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageWindowGeometry.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageWindowGeometry.kt new file mode 100644 index 0000000..e5870f2 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/CoverageWindowGeometry.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.model + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.Point + +data class CoverageWindowGeometry( + val centerLine: List, + val leftBoundary: List, + val rightBoundary: List, + val anchor: Point, + val directionDegrees: Double, + val averageSwathWidth: Double +) diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/SelectedCoverageMode.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/SelectedCoverageMode.kt new file mode 100644 index 0000000..266f090 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/model/SelectedCoverageMode.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.model + +import org.locationtech.jts.geom.Geometry + +data class SelectedCoverageMode( + val candidate: CoverageCandidateMode, + val sequence: Int, + val newCoverage: Geometry, + val overlapWithCovered: Geometry, + val continuityOverlapArea: Double, + val score: Double, + val selectionReason: String +) diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeBuilderService.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeBuilderService.kt new file mode 100644 index 0000000..e62234b --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeBuilderService.kt @@ -0,0 +1,558 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import ballistics.flightLine.PointOnEarthCalculator +import ballistics.types.EarthType +import ballistics.types.Orientation +import ballistics.types.OrbitalPoint +import ballistics.types.THBLPoint +import ballistics.types.WorkCSType +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryFactory +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +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.coverage.CoverageObservationDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import java.time.Duration +import java.time.LocalDateTime +import kotlin.math.PI +import kotlin.math.max +import kotlin.math.min + +@Service +class CoverageSchemeBuilderService( + private val ballisticsClient: CoverageBallisticsClient, + private val satelliteClient: CoverageSatelliteClient, + private val geometrySupport: GeometrySupport +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val geometryFactory = GeometryFactory() + + fun buildCoverageScheme( + observations: List, + rollStepDegrees: Double? = null, + longitudeModel: GeometrySupport.LongitudeModel? = null + ): List { + val satelliteParametersBySatellite = HashMap() + val selectedBranch = selectDominantBranch(observations) + val selectedObservations = observations.filter { it.observation.revSignBegin == selectedBranch } + + logger.info( + "Selected observation branch for coverage scheme: branch={}, ascCount={}, descCount={}, selectedCount={}", + selectedBranch, + observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC }, + observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC }, + selectedObservations.size + ) + + val preparedObservations = selectedObservations.flatMap { observation -> + prepareObservation(observation, satelliteParametersBySatellite, longitudeModel) + }.sortedWith( + compareBy( + { it.leftLongitude }, + { if (selectedBranch == RevolutionSign.DESC) it.observation.observation.gammaMax else 0.0}, + { it.segmentTimeBegin }, + { it.segmentTimeEnd }, + { it.observation.observation.noradId } + ) + ) + + if (preparedObservations.isEmpty()) { + return emptyList() + } + + val selectedContours = ArrayList(preparedObservations.size) + var previousContour: SelectedContour? = null + var requiredRollSign: Int? = null + var coveredTargetArea: Geometry = geometryFactory.createPolygon() + + preparedObservations.forEach { observation -> + val selectedContour = selectContour(observation, previousContour, requiredRollSign) ?: return@forEach + if (!shouldAddContour(selectedContour, coveredTargetArea)) { + return@forEach + } + selectedContours += selectedContour + previousContour = selectedContour + coveredTargetArea = if (coveredTargetArea.isEmpty) { + selectedContour.targetCoverage + } else { + geometrySupport.safeUnion(coveredTargetArea, selectedContour.targetCoverage) + } + if (requiredRollSign == null) { + requiredRollSign = rollSign(selectedContour.roll) + } + } + + return selectedContours.map { selectedContour -> + val observation = selectedContour.observation.observation.observation + val viewPoint = viewParams( + PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit), + selectedContour.observation.orbitalPoints.first().toOrbitalPoint(), + selectedContour.roll + ) + SurveySlotDTO( + satelliteId = observation.noradId, + tn = selectedContour.observation.segmentTimeBegin, + tk = selectedContour.observation.segmentTimeEnd, + roll = selectedContour.roll, + contour = geometrySupport.toWkt(selectedContour.contour), + revolution = observation.revolutionBegin, + revolutionSign = observation.revSignBegin, + latitude = viewPoint?.lat?.toDegrees() ?: 0.0, + longitude = normalizeLongitude( + viewPoint?.long?.toDegrees() ?: 0.0, + selectedContour.observation.longitudeModel + ) + ) + } + } + + private fun prepareObservation( + observation: CoverageObservationDTO, + satelliteParametersBySatellite: MutableMap, + longitudeModel: GeometrySupport.LongitudeModel? + ): List { + val resolvedLongitudeModel = longitudeModel + ?: observation.intersectionContourWkts + .firstOrNull { it.isNotBlank() } + ?.let(geometrySupport::resolveLongitudeModel) + ?: GeometrySupport.LongitudeModel.POSITIVE_360 + val intersectionContour = observation.intersectionContourWkts + .filter { it.isNotBlank() } + .map { geometrySupport.parseAndNormalizeTarget(it, resolvedLongitudeModel) } + .reduceOrNull(geometrySupport::safeUnion) + + if (intersectionContour == null || intersectionContour.isEmpty) { + logger.debug( + "Skip coverage scheme slot because target intersection contour is empty: satelliteId={}, interval=[{} - {}]", + observation.observation.noradId, + observation.observation.timeBegin, + observation.observation.timeEnd + ) + return emptyList() + } + + val satelliteParameters = satelliteParametersBySatellite.getOrPut(observation.observation.noradId) { + satelliteClient.satellite(observation.observation.noradId).toSurveyParameters() + } + + return splitObservationIntervals(observation, satelliteParameters) + .mapNotNull { segment -> + val orbitalPoints = ballisticsClient.exactTime( + observation.observation.noradId, + ExactTimePositionRequestDTO( + time = segment.timeBegin, + timeStop = segment.timeEnd, + stepMs = contourStepMs(segment.timeBegin, segment.timeEnd) + ) + ).sortedBy { it.time } + + if (orbitalPoints.isEmpty()) { + logger.debug( + "Skip coverage scheme slot because orbital points are empty: satelliteId={}, interval=[{} - {}]", + observation.observation.noradId, + segment.timeBegin, + segment.timeEnd + ) + return@mapNotNull null + } + + PreparedObservation( + observation = observation, + intersectionContour = intersectionContour, + orbitalPoints = orbitalPoints, + capture = satelliteParameters.captureAngle, + leftLongitude = intersectionContour.envelopeInternal.minX, + longitudeModel = resolvedLongitudeModel, + segmentTimeBegin = segment.timeBegin, + segmentTimeEnd = segment.timeEnd + ) + } + } + + private fun selectContour( + observation: PreparedObservation, + previousContour: SelectedContour?, + requiredRollSign: Int? + ): SelectedContour? { + val branch = observation.observation.observation.revSignBegin + val defaultRoll = resolveCompatibleRoll( + candidate = defaultRoll(branch, observation), + branch = branch, + observation = observation, + requiredRollSign = requiredRollSign + ) ?: run { + logger.warn( + "Skip coverage scheme slot because interval does not support required roll sign: satelliteId={}, interval=[{} - {}], requiredSign={}", + observation.observation.observation.noradId, + observation.observation.observation.timeBegin, + observation.observation.observation.timeEnd, + requiredRollSign + ) + return null + } + val defaultContour = buildContour(observation, defaultRoll) ?: return null + + if (previousContour == null) { + return defaultContour + } + + if (!intersects(observation.intersectionContour, previousContour.contour)) { + return defaultContour + } + + val rollFromIntersection = resolveIntersectingRoll(observation, previousContour) + ?.let { candidate -> + resolveCompatibleRoll( + candidate = candidate, + branch = branch, + observation = observation, + requiredRollSign = requiredRollSign + ) + } + ?: return defaultContour + return buildContour(observation, rollFromIntersection) ?: defaultContour + } + + private fun buildContour(observation: PreparedObservation, roll: Double): SelectedContour? { + val contour = contour(observation.orbitalPoints, roll, observation.capture, observation.intersectionContour) ?: return null + if (contour.isEmpty) { + return null + } + val targetCoverage = geometrySupport.safeIntersection(contour, observation.intersectionContour) + return SelectedContour( + observation = observation, + roll = roll, + contour = contour, + targetCoverage = targetCoverage, + overlapAnchor = overlapAnchorPoint( + branch = observation.observation.observation.revSignBegin, + points = observation.orbitalPoints, + roll = roll, + capture = observation.capture, + longitudeModel = observation.longitudeModel + ) ?: contour.coordinates.firstOrNull() + ) + } + + private fun contour(points: List, roll: Double, capture: Double, target: Geometry): Geometry? { + return try { + val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + val rightCoordinates = ArrayList(points.size) + val leftCoordinates = ArrayList(points.size) + + points.map { it.toOrbitalPoint() }.forEach { point -> + val rightPoint = viewParams(pointOnEarthCalculator, point, roll + capture) ?: THBLPoint(0.0, 0.0, 0.0, 0.0) + rightCoordinates += Coordinate(rightPoint.long.toDegrees(), rightPoint.lat.toDegrees(), 0.0) + + val leftPoint = viewParams(pointOnEarthCalculator, point, roll - capture) ?: THBLPoint(0.0, 0.0, 0.0, 0.0) + leftCoordinates += Coordinate(leftPoint.long.toDegrees(), leftPoint.lat.toDegrees(), 0.0) + } + + val alignedRightCoordinates = geometrySupport.alignTrackCoordinatesToTarget(rightCoordinates, target) + val alignedLeftCoordinates = geometrySupport.alignTrackCoordinatesToTarget(leftCoordinates, target) + val shellCoordinates = ArrayList(rightCoordinates.size + leftCoordinates.size + 1) + shellCoordinates.addAll(alignedRightCoordinates) + for (index in alignedLeftCoordinates.indices.reversed()) { + shellCoordinates += alignedLeftCoordinates[index] + } + if (shellCoordinates.isNotEmpty()) { + shellCoordinates += Coordinate(shellCoordinates.first().x, shellCoordinates.first().y, 0.0) + } + if (shellCoordinates.size < 4) { + return null + } + + val shell = geometryFactory.createLinearRing(shellCoordinates.toTypedArray()) + geometryFactory.createPolygon(shell).buffer(0.0) + } catch (error: Exception) { + logger.warn("Failed to build coverage scheme contour", error) + null + } + } + + private fun viewParams( + pointOnEarthCalculator: PointOnEarthCalculator, + point: OrbitalPoint, + gamma: Double + ): THBLPoint? = + pointOnEarthCalculator.pointOnEarth(point, Orientation(0.0, gamma * PI / 180.0, 0.0)) + + private fun selectDominantBranch(observations: List) = + if (observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC } >= + observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC } + ) { + space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC + } else { + space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC + } + + private fun intersects(first: Geometry, second: Geometry): Boolean = + !geometrySupport.safeIntersection(first, second).isEmpty + + private fun defaultRoll(branch: RevolutionSign, observation: PreparedObservation): Double = + when (branch) { + RevolutionSign.ASC -> max(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax) + RevolutionSign.DESC -> min(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax) + } + + private fun resolveCompatibleRoll( + candidate: Double, + branch: RevolutionSign, + observation: PreparedObservation, + requiredRollSign: Int? + ): Double? { + if (requiredRollSign == null || rollSign(candidate) == requiredRollSign) { + return candidate + } + + val gammaMin = min(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax) + val gammaMax = max(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax) + + return when (requiredRollSign) { + 1 -> when { + gammaMin > 0.0 && branch == RevolutionSign.DESC -> gammaMin + gammaMax > 0.0 -> gammaMax + else -> null + } + -1 -> when { + gammaMax < 0.0 && branch == RevolutionSign.ASC -> gammaMax + gammaMin < 0.0 -> gammaMin + else -> null + } + else -> if (gammaMin <= 0.0 && gammaMax >= 0.0) 0.0 else null + } + } + + private fun resolveIntersectingRoll( + observation: PreparedObservation, + previousContour: SelectedContour + ): Double? { + val previousContourCoordinates = previousContour.contour.coordinates + if (previousContourCoordinates.size < 4) { + logger.warn( + "Unable to resolve intersection roll because previous contour geometry is too short: previousSatelliteId={}, currentSatelliteId={}, points={}", + previousContour.observation.observation.observation.noradId, + observation.observation.observation.noradId, + previousContourCoordinates.size + ) + return null + } + + val rightmostStartPoint = listOf( + previousContourCoordinates.first(), + previousContourCoordinates[previousContourCoordinates.size - 2] + ).maxByOrNull { it.x } ?: return null + + val mplPoints = ballisticsClient.mplPoint( + ObjViewRequestDTO( + satellites = listOf(observation.observation.observation.noradId), + timeStart = observation.segmentTimeBegin.minusSeconds(200), + timeStop = observation.segmentTimeEnd.plusSeconds(200), + obj = ObjDTO( + id = "coverage-scheme-overlap", + position = PositionDTO( + lat = rightmostStartPoint.y, + long = normalizeLongitude(rightmostStartPoint.x, observation.longitudeModel), + height = 0.0 + ) + ) + ) + ) + + if (mplPoints.size != 1) { + logger.warn( + "Intersection roll lookup returned unexpected mplPoint count, fallback to no-intersection behavior: currentSatelliteId={}, interval=[{} - {}], mplPointCount={}", + observation.observation.observation.noradId, + observation.segmentTimeBegin, + observation.segmentTimeEnd, + mplPoints.size + ) + return null + } + + val mplGamma = mplPoints.single().gamma + val currentPoint = observation.orbitalPoints.firstOrNull()?.toOrbitalPoint() ?: return null + val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + val captureHalf = observation.capture + val gammaCandidates = listOf(mplGamma - captureHalf, mplGamma + captureHalf) + val longitudeReference = rightmostStartPoint.x + + return gammaCandidates + .mapNotNull { gamma -> + pointOnEarthCalculator.pointOnEarth( + currentPoint, + Orientation(0.0, gamma * PI / 180.0, 0.0) + )?.let { pointOnEarth -> + var adjustedLongitude = pointOnEarth.long.toDegrees() + while (adjustedLongitude - longitudeReference > 180.0) adjustedLongitude -= 360.0 + while (adjustedLongitude - longitudeReference < -180.0) adjustedLongitude += 360.0 + gamma to adjustedLongitude + } + } + .maxByOrNull { (_, longitude) -> longitude } + ?.first + } + + private fun shouldAddContour(candidate: SelectedContour, coveredTargetArea: Geometry): Boolean { + if (candidate.targetCoverage.isEmpty || candidate.targetCoverage.area <= 0.0) { + logger.info( + "Skip coverage scheme slot because candidate does not intersect required target area: satelliteId={}, interval=[{} - {}]", + candidate.observation.observation.observation.noradId, + candidate.observation.segmentTimeBegin, + candidate.observation.segmentTimeEnd + ) + return false + } + + if (coveredTargetArea.isEmpty) { + return true + } + + val overlapArea = geometrySupport.safeIntersection(candidate.targetCoverage, coveredTargetArea).area + val overlapRatio = overlapArea / candidate.targetCoverage.area + if (overlapRatio > 0.9) { + logger.info( + "Skip coverage scheme slot because candidate overlaps existing coverage too much: satelliteId={}, interval=[{} - {}], overlapRatio={}", + candidate.observation.observation.observation.noradId, + candidate.observation.segmentTimeBegin, + candidate.observation.segmentTimeEnd, + overlapRatio + ) + return false + } + + return true + } + + private fun overlapAnchorPoint( + branch: RevolutionSign, + points: List, + roll: Double, + capture: Double, + longitudeModel: GeometrySupport.LongitudeModel + ): Coordinate? { + val firstPoint = points.firstOrNull()?.toOrbitalPoint() ?: return null + val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + val boundaryPoint = when (branch) { + RevolutionSign.ASC -> viewParams(pointOnEarthCalculator, firstPoint, roll - capture) + RevolutionSign.DESC -> viewParams(pointOnEarthCalculator, firstPoint, roll + capture) + } ?: return null + return Coordinate( + normalizeLongitude(boundaryPoint.long.toDegrees(), longitudeModel), + boundaryPoint.lat.toDegrees(), + 0.0 + ) + } + + private fun splitObservationIntervals( + observation: CoverageObservationDTO, + satelliteParameters: SatelliteSurveyParameters + ): List { + val maxDurationSeconds = satelliteParameters.maxSurveyDurationSeconds.coerceAtLeast(1L) + val mmiSeconds = satelliteParameters.mmiSeconds.coerceAtLeast(0L) + val segments = mutableListOf() + var segmentStart = observation.observation.timeBegin + + while (segmentStart.isBefore(observation.observation.timeEnd)) { + val segmentEnd = minOf( + segmentStart.plusSeconds(maxDurationSeconds), + observation.observation.timeEnd + ) + if (!segmentEnd.isAfter(segmentStart)) { + break + } + segments += ObservationSegment(segmentStart, segmentEnd) + if (!segmentEnd.isBefore(observation.observation.timeEnd)) { + break + } + segmentStart = segmentEnd.plusSeconds(mmiSeconds) + } + + return segments + } + + private fun contourStepMs(timeBegin: LocalDateTime, timeEnd: LocalDateTime): Long { + val durationMs = max(1L, Duration.between(timeBegin, timeEnd).toMillis()) + return max(1L, durationMs / 5L) + } + + private fun rollSign(roll: Double): Int = when { + roll > 0.0 -> 1 + roll < 0.0 -> -1 + else -> 0 + } + + private fun normalizeLongitude(longitude: Double, longitudeModel: GeometrySupport.LongitudeModel): Double = + when (longitudeModel) { + GeometrySupport.LongitudeModel.STANDARD_180 -> { + var normalized = longitude + while (normalized > 180.0) normalized -= 360.0 + while (normalized <= -180.0) normalized += 360.0 + normalized + } + GeometrySupport.LongitudeModel.POSITIVE_360 -> { + var normalized = longitude + while (normalized < 0.0) normalized += 360.0 + while (normalized >= 360.0) normalized -= 360.0 + normalized + } + } + + private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint( + t = fromDateTime(time), + vit = revolution.toInt(), + r = Vector3D(x, y, z), + v = Vector3D(vx, vy, vz) + ) + + private fun Double.toDegrees(): Double = this * 180.0 / PI + + private data class PreparedObservation( + val observation: CoverageObservationDTO, + val intersectionContour: Geometry, + val orbitalPoints: List, + val capture: Double, + val leftLongitude: Double, + val longitudeModel: GeometrySupport.LongitudeModel, + val segmentTimeBegin: LocalDateTime, + val segmentTimeEnd: LocalDateTime + ) + + private data class SelectedContour( + val observation: PreparedObservation, + val roll: Double, + val contour: Geometry, + val targetCoverage: Geometry, + val overlapAnchor: Coordinate? + ) + + private data class SatelliteSurveyParameters( + val captureAngle: Double, + val maxSurveyDurationSeconds: Long, + val mmiSeconds: Long + ) + + private data class ObservationSegment( + val timeBegin: LocalDateTime, + val timeEnd: LocalDateTime + ) + + private fun SatelliteInfoDTO.toSurveyParameters() = SatelliteSurveyParameters( + captureAngle = captureAngle, + maxSurveyDurationSeconds = maxSurveyDurationSeconds ?: 300L, + mmiSeconds = mmiSeconds ?: 10L + ) +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeCalculationService.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeCalculationService.kt new file mode 100644 index 0000000..5466414 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeCalculationService.kt @@ -0,0 +1,266 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryCollection +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO + +@Service +class CoverageSchemeCalculationService( + private val ballisticsClient: CoverageBallisticsClient, + private val coverageSchemeBuilderService: CoverageSchemeBuilderService, + private val geometrySupport: GeometrySupport, + private val properties: CoverageSchemeProperties +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val geometryFactory = GeometryFactory() + + fun calculate(request: CoverageSchemeCalculateRequestDTO): CoverageSchemeResponseDTO { + validateRequest(request) + val longitudeModel = geometrySupport.resolveLongitudeModel(request.polygonWkt) + val target = geometrySupport.parseAndNormalizeTarget(request.polygonWkt, longitudeModel) + val views = ballisticsClient.mplSquare( + ObjViewRequestDTO( + satellites = request.satelliteIds, + timeStart = request.timeStart, + timeStop = request.timeEnd, + obj = ObjDTO( + id = "coverage-scheme-target", + contourWKT = request.polygonWkt + ), + sunAngleMin = request.sunAngle + ) + ) + .sortedWith( + compareBy( + { it.noradId }, + { it.timeBegin }, + { it.timeEnd }, + { it.revolutionBegin }, + { it.revolutionEnd }, + { it.gammaMin }, + { it.gammaMax } + ) + ) + + logger.info("Получено ${views.size} моментов наблюдений") + + val observations = views + .map { observation -> + CoverageObservationDTO( + observation = observation, + intersectionContourWkts = buildIntersectionContourWkts(target, observation, longitudeModel) + ) + } + val coverageScheme = coverageSchemeBuilderService.buildCoverageScheme( + observations = observations, + rollStepDegrees = request.rollStepDegrees, + longitudeModel = longitudeModel + ) + + logger.info( + "Coverage scheme calculated: observations={}, slots={}, satellites={}", + observations.size, + coverageScheme.size, + request.satelliteIds.size + ) + + return CoverageSchemeResponseDTO( + targetPolygonWkt = geometrySupport.toWkt(target), + observations = observations, + coverageScheme = coverageScheme + ) + } + + private fun buildIntersectionContourWkts( + target: Geometry, + observation: SquareViewParamDTO, + longitudeModel: GeometrySupport.LongitudeModel + ): List { + val paddedStart = observation.timeBegin.minusSeconds(properties.flightLinePaddingSeconds) + val paddedEnd = observation.timeEnd.plusSeconds(properties.flightLinePaddingSeconds) + val flightLine = ballisticsClient.flightLine(observation.noradId, paddedStart, paddedEnd) + .map { normalizeFlightLine(it, longitudeModel) } + .sortedBy { it.time } + + if (flightLine.size < 2) { + logger.debug( + "Skip strip intersection because flight line is too short: satelliteId={}, interval=[{} - {}], points={}", + observation.noradId, + observation.timeBegin, + observation.timeEnd, + flightLine.size + ) + return emptyList() + } + + val strip = buildStripGeometry(target, flightLine) + if (strip.isEmpty) { + return emptyList() + } + + val intersection = geometrySupport.safeIntersection(strip, target) + return extractPolygons(intersection) + .map(geometrySupport::toWkt) + .filter { it.isNotBlank() } + } + + private fun normalizeFlightLine( + point: FlightLineDTO, + longitudeModel: GeometrySupport.LongitudeModel + ) = FlightLineDTO( + time = point.time, + revolution = point.revolution, + lat = point.lat, + long = normalizeLongitude(point.long, longitudeModel), + latLeft = point.latLeft, + longLeft = normalizeLongitude(point.longLeft, longitudeModel), + latInnerLeft = point.latInnerLeft, + longInnerLeft = normalizeLongitude(point.longInnerLeft, longitudeModel), + latInnerRight = point.latInnerRight, + longInnerRight = normalizeLongitude(point.longInnerRight, longitudeModel), + latRight = point.latRight, + longRight = normalizeLongitude(point.longRight, longitudeModel), + revSign = point.revSign + ) + + private fun normalizeLongitude( + longitude: Double, + longitudeModel: GeometrySupport.LongitudeModel + ): Double = when (longitudeModel) { + GeometrySupport.LongitudeModel.STANDARD_180 -> normalizeToStandardBand(longitude) + GeometrySupport.LongitudeModel.POSITIVE_360 -> normalizeToPositiveBand(longitude) + } + + private fun normalizeToStandardBand(longitude: Double): Double { + var normalized = longitude % 360.0 + if (normalized <= -180.0) { + normalized += 360.0 + } + if (normalized > 180.0) { + normalized -= 360.0 + } + return normalized + } + + private fun normalizeToPositiveBand(longitude: Double): Double { + var normalized = longitude % 360.0 + if (normalized < 0.0) { + normalized += 360.0 + } + return normalized + } + + private fun buildStripGeometry(target: Geometry, flightLine: List): Geometry { + val outerLeftBoundary = geometrySupport.alignTrackCoordinatesToTarget( + flightLine.map { Coordinate(it.longLeft, it.latLeft, 0.0) }, + target + ) + val outerRightBoundary = geometrySupport.alignTrackCoordinatesToTarget( + flightLine.map { Coordinate(it.longRight, it.latRight, 0.0) }, + target + ) + + if (outerLeftBoundary.size < 2 || outerRightBoundary.size < 2) { + return geometryFactory.createPolygon() + } + + val innerLeftBoundary = geometrySupport.alignTrackCoordinatesToTarget( + flightLine.map { Coordinate(it.longInnerLeft, it.latInnerLeft, 0.0) }, + target + ) + val innerRightBoundary = geometrySupport.alignTrackCoordinatesToTarget( + flightLine.map { Coordinate(it.longInnerRight, it.latInnerRight, 0.0) }, + target + ) + + val geometries = listOfNotNull( + buildRibbonPolygon(outerLeftBoundary, innerLeftBoundary, flightLine), + buildRibbonPolygon(innerRightBoundary, outerRightBoundary, flightLine) + ) + if (geometries.isEmpty()) { + return geometryFactory.createPolygon() + } + return geometries.drop(1).fold(geometries.first()) { merged, geometry -> + geometrySupport.safeUnion(merged, geometry) + } + } + + private fun buildRibbonPolygon( + leftBoundary: List, + rightBoundary: List, + flightLine: List + ): Polygon? { + val shell = toRingCoordinates(leftBoundary, rightBoundary) ?: return null + return runCatching { + geometryFactory.createPolygon(shell.toTypedArray()) + }.getOrElse { error -> + logger.warn( + "Failed to build strip segment polygon: firstPointTime={}, points={}, error={}", + flightLine.firstOrNull()?.time, + flightLine.size, + error.message + ) + null + } + } + + private fun toRingCoordinates(leftBoundary: List, rightBoundary: List): List? { + if (leftBoundary.size < 2 || rightBoundary.size < 2) { + return null + } + val ring = ArrayList(leftBoundary.size + rightBoundary.size + 1) + leftBoundary.forEach { ring += Coordinate(it) } + rightBoundary.asReversed().forEach { ring += Coordinate(it) } + if (ring.size < 4) { + return null + } + ring += Coordinate(ring.first()) + + val distinctVertices = ring + .dropLast(1) + .map { it.x to it.y } + .distinct() + return ring.takeIf { distinctVertices.size >= 3 } + } + + private fun extractPolygons(geometry: Geometry): List { + if (geometry.isEmpty) return emptyList() + return when (geometry) { + is Polygon -> listOf(geometry) + is MultiPolygon -> (0 until geometry.numGeometries) + .map { geometry.getGeometryN(it) as Polygon } + is GeometryCollection -> (0 until geometry.numGeometries) + .flatMap { extractPolygons(geometry.getGeometryN(it)) } + else -> emptyList() + } + } + + private fun validateRequest(request: CoverageSchemeCalculateRequestDTO) { + val minimumTechnologyOverlap = request.minimumTechnologyOverlap + if (request.satelliteIds.isEmpty()) { + throw CustomValidationException("satelliteIds must not be empty") + } + if (!request.timeStart.isBefore(request.timeEnd)) { + throw CustomValidationException("timeStart must be before timeEnd") + } + if (minimumTechnologyOverlap != null && minimumTechnologyOverlap < 0.0) { + throw CustomValidationException("minimumTechnologyOverlap must be non-negative") + } + } +} diff --git a/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/GeometrySupport.kt b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/GeometrySupport.kt new file mode 100644 index 0000000..8dcc863 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/GeometrySupport.kt @@ -0,0 +1,372 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.CoordinateSequenceFilter +import org.locationtech.jts.geom.Envelope +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryCollection +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.LineString +import org.locationtech.jts.geom.LinearRing +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Point +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.io.WKTWriter +import org.locationtech.jts.operation.buffer.BufferOp +import org.locationtech.jts.operation.buffer.BufferParameters +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomValidationException +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +@Component +class GeometrySupport { + + enum class LongitudeModel { + STANDARD_180, + POSITIVE_360 + } + + data class StripOrientation( + val axisX: Double, + val axisY: Double, + val normalX: Double, + val normalY: Double + ) + + private val geometryFactory = GeometryFactory() + private val wktReader = WKTReader(geometryFactory) + private val wktWriter = WKTWriter() + + fun resolveLongitudeModel(wkt: String): LongitudeModel { + if (wkt.isBlank()) { + throw CustomValidationException("polygonWkt must not be blank") + } + val geometry = try { + wktReader.read(wkt) + } catch (ex: Exception) { + throw CustomValidationException("Некорректный WKT полигона: ${ex.message}") + } + return resolveLongitudeModel(geometry) + } + + fun parseAndNormalizeTarget(wkt: String, longitudeModel: LongitudeModel? = null): Geometry { + if (wkt.isBlank()) { + throw CustomValidationException("polygonWkt must not be blank") + } + val geometry = try { + wktReader.read(wkt) + } catch (ex: Exception) { + throw CustomValidationException("Некорректный WKT полигона: ${ex.message}") + } + val normalized = normalizeToLongitudeModel(geometry, longitudeModel ?: resolveLongitudeModel(geometry)) + if (normalized !is Polygon && normalized !is MultiPolygon) { + throw CustomValidationException("Поддерживаются только Polygon или MultiPolygon") + } + return ensureValid(normalized) + } + + fun toWkt(geometry: Geometry): String = + if (geometry.isEmpty) "" else wktWriter.write(ensureValid(geometry)) + + fun safeIntersection(left: Geometry, right: Geometry): Geometry = + safeOverlay(left, right) { first, second -> first.intersection(second) } + + fun safeDifference(left: Geometry, right: Geometry): Geometry = + safeOverlay(left, right) { first, second -> first.difference(second) } + + fun safeUnion(left: Geometry, right: Geometry): Geometry = + safeOverlay(left, right) { first, second -> first.union(second) } + + fun centroid(geometry: Geometry): Point = + if (geometry.isEmpty) point(0.0, 0.0) else geometry.centroid + + fun point(x: Double, y: Double): Point = + geometryFactory.createPoint(Coordinate(x, y)) + + fun buildStripOrientation(directionDegrees: Double): StripOrientation { + val radians = Math.toRadians(directionDegrees) + val axisX = cos(radians) + val axisY = sin(radians) + return StripOrientation( + axisX = axisX, + axisY = axisY, + normalX = -axisY, + normalY = axisX + ) + } + + fun projectionRange(geometry: Geometry, orientation: StripOrientation): Pair { + if (geometry.isEmpty) return 0.0 to 0.0 + val values = geometry.coordinates.map { project(it.x, it.y, orientation.axisX, orientation.axisY) } + return (values.minOrNull() ?: 0.0) to (values.maxOrNull() ?: 0.0) + } + + fun projectPoint(point: Point, orientation: StripOrientation): Double = + project(point.x, point.y, orientation.axisX, orientation.axisY) + + fun createCorridor(centerLine: List, width: Double, centerShift: Double = 0.0): Polygon { + val shifted = shiftCoordinates(centerLine, buildStripOrientation(directionDegrees(centerLine)), centerShift) + val line = when { + shifted.isEmpty() -> geometryFactory.createLineString(arrayOf(Coordinate(0.0, 0.0), Coordinate(0.0, 0.0))) + shifted.size == 1 -> geometryFactory.createLineString(arrayOf(shifted.first(), shifted.first())) + else -> geometryFactory.createLineString(shifted.toTypedArray()) + } + val buffered = BufferOp.bufferOp( + line, + max(width / 2.0, 0.000001), + BufferParameters(8, BufferParameters.CAP_FLAT, BufferParameters.JOIN_ROUND, BufferParameters.DEFAULT_MITRE_LIMIT) + ) + return ensureValid(buffered) as Polygon + } + + fun shiftPoint(point: Point, orientation: StripOrientation, distance: Double): Point = + this.point( + point.x + orientation.normalX * distance, + point.y + orientation.normalY * distance + ) + + fun directionDegrees(coordinates: List): Double { + if (coordinates.size < 2) return 0.0 + val normalized = normalizeTrackCoordinates(coordinates) + val first = normalized.first() + val last = normalized.last() + return Math.toDegrees(atan2(last.y - first.y, last.x - first.x)) + } + + fun normalizeTrackCoordinates(coordinates: List): List { + if (coordinates.isEmpty()) return emptyList() + val normalized = mutableListOf(Coordinate(coordinates.first())) + var previousX = coordinates.first().x + coordinates.drop(1).forEach { coordinate -> + var adjustedX = coordinate.x + while (adjustedX - previousX > 180.0) adjustedX -= 360.0 + while (adjustedX - previousX < -180.0) adjustedX += 360.0 + normalized += Coordinate(adjustedX, coordinate.y, coordinate.z) + previousX = adjustedX + } + return normalized + } + + fun alignTrackCoordinatesToTarget(coordinates: List, target: Geometry): List { + val normalized = normalizeTrackCoordinates(coordinates) + if (normalized.isEmpty()) return normalized + val referenceX = target.envelopeInternal.centre().x + val averageX = normalized.map { it.x }.average() + val shiftMultiplier = kotlin.math.round((referenceX - averageX) / 360.0) + val shift = shiftMultiplier * 360.0 + if (shift == 0.0) return normalized + return normalized.map { coordinate -> + Coordinate(coordinate.x + shift, coordinate.y, coordinate.z) + } + } + + fun distance(first: Coordinate, second: Coordinate): Double { + val dx = second.x - first.x + val dy = second.y - first.y + return kotlin.math.sqrt(dx * dx + dy * dy) + } + + fun boundaryTouchArea(coverage: Geometry, target: Geometry, orientation: StripOrientation, buffer: Double): Double { + val bands = boundaryBands(target, buffer) + return bands.maxOf { (_, band) -> safeIntersection(coverage, band).area } + } + + fun frontierGeometry(target: Geometry, covered: Geometry): Geometry { + if (covered.isEmpty) return ensureValid(target.boundary.buffer(0.0001)) + val uncovered = safeDifference(target, covered) + if (uncovered.isEmpty) return geometryFactory.createPolygon() + return ensureValid(safeIntersection(covered.buffer(0.0001), uncovered.buffer(0.0001))) + } + + fun uncoveredFrontierGeometry(target: Geometry, covered: Geometry): Geometry { + val uncovered = safeDifference(target, covered) + if (uncovered.isEmpty) return geometryFactory.createPolygon() + return ensureValid(safeIntersection(uncovered, frontierGeometry(target, covered).buffer(0.0001))) + } + + fun startBoundaryLabel(coverage: Geometry, target: Geometry, orientation: StripOrientation): String { + val bands = boundaryBands(target, 0.05) + return bands.maxByOrNull { (_, band) -> safeIntersection(coverage, band).area }?.first ?: "INTERIOR" + } + + fun shiftCoordinates(centerLine: List, orientation: StripOrientation, distance: Double): List = + centerLine.map { coordinate -> + Coordinate( + coordinate.x + orientation.normalX * distance, + coordinate.y + orientation.normalY * distance, + coordinate.z + ) + } + + private fun boundaryBands(target: Geometry, buffer: Double): List> { + val envelope = target.envelopeInternal + val dx = max(buffer, (envelope.width * 0.02).coerceAtLeast(0.0001)) + val dy = max(buffer, (envelope.height * 0.02).coerceAtLeast(0.0001)) + return listOf( + "WEST" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.minX + dx, envelope.minY - dy, envelope.maxY + dy)), + "EAST" to geometryFactory.toGeometry(Envelope(envelope.maxX - dx, envelope.maxX + dx, envelope.minY - dy, envelope.maxY + dy)), + "SOUTH" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.maxX + dx, envelope.minY - dy, envelope.minY + dy)), + "NORTH" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.maxX + dx, envelope.maxY - dy, envelope.maxY + dy)) + ) + } + + private fun ensureValid(geometry: Geometry): Geometry = + if (geometry.isValid) geometry else geometry.buffer(0.0) + + private fun safeOverlay(left: Geometry, right: Geometry, operation: (Geometry, Geometry) -> Geometry): Geometry = + try { + ensureValid(operation(ensureValid(left), ensureValid(right))) + } catch (_: Exception) { + ensureValid(operation(left.buffer(0.0), right.buffer(0.0))) + } + + private fun project(x: Double, y: Double, axisX: Double, axisY: Double): Double = x * axisX + y * axisY + + private fun resolveLongitudeModel(geometry: Geometry): LongitudeModel { + val longitudes = geometry.coordinates.map { normalizeToStandardLongitude(it.x) } + val negativeMin = longitudes.filter { it < 0.0 }.minOrNull() + val positiveMax = longitudes.filter { it >= 0.0 }.maxOrNull() + val crossesZeroMeridian = negativeMin != null && positiveMax != null && positiveMax - negativeMin < 180.0 + return if (crossesZeroMeridian) LongitudeModel.STANDARD_180 else LongitudeModel.POSITIVE_360 + } + + private fun normalizeToLongitudeModel(geometry: Geometry, longitudeModel: LongitudeModel): Geometry { + val normalized = when (longitudeModel) { + LongitudeModel.STANDARD_180 -> shiftLongitudesToStandardBand(geometry.copy()) + LongitudeModel.POSITIVE_360 -> shiftNegativeLongitudesTo360(geometry.copy()) + } + return when (normalized) { + is Polygon -> normalizePolygon(normalized, longitudeModel) + is MultiPolygon -> normalizeMultiPolygon(normalized, longitudeModel) + else -> normalized + }.let { ensureValid(it) } + } + + private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry { + geometry.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + val lon = seq.getX(i) + if (lon < 0.0) { + seq.setOrdinate(i, 0, lon + 360.0) + } + } + + override fun isDone(): Boolean = false + override fun isGeometryChanged(): Boolean = true + }) + geometry.geometryChanged() + return geometry + } + + private fun shiftLongitudesToStandardBand(geometry: Geometry): Geometry { + geometry.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + seq.setOrdinate(i, 0, normalizeToStandardLongitude(seq.getX(i))) + } + + override fun isDone(): Boolean = false + override fun isGeometryChanged(): Boolean = true + }) + geometry.geometryChanged() + return geometry + } + + private fun normalizePolygon(polygon: Polygon, longitudeModel: LongitudeModel): Geometry { + val shell = unwrapRing(polygon.exteriorRing) + val holes = Array(polygon.numInteriorRing) { index -> unwrapRing(polygon.getInteriorRingN(index)) } + val normalized = ensureValid(polygon.factory.createPolygon(shell, holes)) + return when (longitudeModel) { + LongitudeModel.STANDARD_180 -> shiftIntoStandardBand(normalized) + LongitudeModel.POSITIVE_360 -> shiftIntoPositiveBand(normalized) + } + } + + private fun normalizeMultiPolygon(multiPolygon: MultiPolygon, longitudeModel: LongitudeModel): Geometry { + val polygons = mutableListOf() + for (index in 0 until multiPolygon.numGeometries) { + collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon, longitudeModel), polygons) + } + return when (polygons.size) { + 0 -> geometryFactory.createPolygon() + 1 -> polygons.first() + else -> geometryFactory.createMultiPolygon(polygons.toTypedArray()) + } + } + + private fun collectPolygons(geometry: Geometry, polygons: MutableList) { + when (geometry) { + is Polygon -> polygons += geometry + is MultiPolygon -> { + for (index in 0 until geometry.numGeometries) { + polygons += geometry.getGeometryN(index) as Polygon + } + } + is GeometryCollection -> { + for (index in 0 until geometry.numGeometries) { + collectPolygons(geometry.getGeometryN(index), polygons) + } + } + } + } + + private fun shiftIntoPositiveBand(geometry: Geometry): Geometry { + val minX = geometry.coordinates.minOfOrNull { it.x } ?: return geometry + if (minX >= 0.0) return geometry + val shift = 360.0 * (floor(-minX / 360.0) + 1.0) + return shiftLongitude(geometry, shift) + } + + private fun shiftIntoStandardBand(geometry: Geometry): Geometry { + val centerX = geometry.envelopeInternal.centre().x + val shift = -360.0 * floor((centerX + 180.0) / 360.0) + return shiftLongitude(geometry, shift) + } + + private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry { + if (delta == 0.0) return geometry + val shifted = geometry.copy() + shifted.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + seq.setOrdinate(i, 0, seq.getX(i) + delta) + } + + override fun isDone(): Boolean = false + override fun isGeometryChanged(): Boolean = true + }) + shifted.geometryChanged() + return shifted + } + + private fun unwrapRing(ring: LineString): LinearRing { + val source = ring.coordinates + if (source.isEmpty()) return ring.factory.createLinearRing() + val unwrapped = ArrayList(source.size) + var previousX = source.first().x + unwrapped.add(Coordinate(source.first().x, source.first().y, source.first().z)) + for (index in 1 until source.size - 1) { + val current = source[index] + var adjustedX = current.x + while (adjustedX - previousX > 180.0) adjustedX -= 360.0 + while (adjustedX - previousX < -180.0) adjustedX += 360.0 + unwrapped += Coordinate(adjustedX, current.y, current.z) + previousX = adjustedX + } + val first = unwrapped.first() + unwrapped += Coordinate(first.x, first.y, first.z) + return ring.factory.createLinearRing(unwrapped.toTypedArray()) + } + + private fun normalizeToStandardLongitude(longitude: Double): Double { + var normalized = longitude + while (normalized > 180.0) normalized -= 360.0 + while (normalized <= -180.0) normalized += 360.0 + return normalized + } +} diff --git a/services/pcp-coverage-scheme-service/src/main/resources/application.yaml b/services/pcp-coverage-scheme-service/src/main/resources/application.yaml new file mode 100644 index 0000000..e07ab9f --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-coverage-scheme-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} diff --git a/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/controller/CoverageSchemeControllerTest.kt b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/controller/CoverageSchemeControllerTest.kt new file mode 100644 index 0000000..aca2b36 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/controller/CoverageSchemeControllerTest.kt @@ -0,0 +1,97 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.springframework.boot.test.context.SpringBootTest +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 space.nstart.pcp.pcp_coverage_scheme_service.service.CoverageSchemeCalculationService +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import kotlin.test.assertEquals + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.boot.admin.client.enabled=false", + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.config.import=" + ] +) +class CoverageSchemeControllerTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var coverageSchemeCalculationService: CoverageSchemeCalculationService + + private val objectMapper = ObjectMapper() + + @Test + fun `calculate endpoint returns observations with contours`() { + doReturn( + CoverageSchemeResponseDTO( + targetPolygonWkt = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", + observations = listOf( + CoverageObservationDTO( + observation = SquareViewParamDTO( + noradId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5), + revSignBegin = RevolutionSign.ASC, + revSignEnd = RevolutionSign.ASC, + gammaMin = 0.0, + gammaMax = 10.0 + ), + intersectionContourWkts = listOf("POLYGON ((0.5 0, 1.5 0, 1.5 2, 0.5 2, 0.5 0))") + ) + ), + coverageScheme = listOf( + SurveySlotDTO( + satelliteId = 11L, + tn = LocalDateTime.of(2026, 4, 14, 10, 0), + tk = LocalDateTime.of(2026, 4, 14, 10, 5), + roll = 5.0, + contour = "POLYGON ((0.5 0, 1.5 0, 1.5 2, 0.5 2, 0.5 0))", + revolutionSign = RevolutionSign.ASC + ) + ) + ) + ) + .`when`(coverageSchemeCalculationService) + .calculate(org.mockito.ArgumentMatchers.any(CoverageSchemeCalculateRequestDTO::class.java) ?: CoverageSchemeCalculateRequestDTO()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/coverage-schemes/calculate") + .bodyValue( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", + satelliteIds = listOf(11L), + timeStart = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 11, 0) + ) + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual["observations"].size()) + assertEquals(1, actual["coverageScheme"].size()) + assertEquals(11L, actual["observations"][0]["observation"]["noradId"].asLong()) + assertEquals( + "POLYGON ((0.5 0, 1.5 0, 1.5 2, 0.5 2, 0.5 0))", + actual["observations"][0]["intersectionContourWkts"][0].textValue() + ) + } +} diff --git a/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageCandidateGenerationServiceTest.kt b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageCandidateGenerationServiceTest.kt new file mode 100644 index 0000000..4a0570a --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageCandidateGenerationServiceTest.kt @@ -0,0 +1,328 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoverageSchemeCalculationServiceTest { + + private val geometrySupport = GeometrySupport() + + @Test + fun `calculate returns all mplSquare observations with strip intersection contours`() { + val observation = SquareViewParamDTO( + noradId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5), + revSignBegin = RevolutionSign.ASC, + revSignEnd = RevolutionSign.ASC, + gammaMin = 0.0, + gammaMax = 0.0 + ) + val fakeClient = FakeBallisticsClient( + observations = listOf(observation), + flightLinesBySatellite = mapOf( + 11L to listOf( + flightPoint(LocalDateTime.of(2026, 4, 14, 10, 0), lat = 0.0, lon = 196.0, leftLon = 195.5, rightLon = 196.5), + flightPoint(LocalDateTime.of(2026, 4, 14, 10, 5), lat = 2.0, lon = 196.0, leftLon = 195.5, rightLon = 196.5) + ) + ), + orbitalPointsBySatellite = mapOf(11L to sampleOrbitalPoints()) + ) + val service = buildService(fakeClient) + + val response = service.calculate( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))", + satelliteIds = listOf(11L), + timeStart = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10) + ) + ) + + assertEquals(1, response.observations.size) + assertEquals(observation.noradId, response.observations.first().observation.noradId) + assertEquals(observation.timeBegin, response.observations.first().observation.timeBegin) + assertEquals(observation.timeEnd, response.observations.first().observation.timeEnd) + assertEquals(1, response.observations.first().intersectionContourWkts.size) + assertTrue(response.observations.first().intersectionContourWkts.first().contains("195.5 0")) + assertTrue(response.observations.first().intersectionContourWkts.first().contains("196.5 2")) + assertEquals(1, response.coverageScheme.size) + assertEquals(0.0, response.coverageScheme.first().roll) + assertEquals(observation.timeBegin, response.coverageScheme.first().tn) + assertEquals(observation.timeEnd, response.coverageScheme.first().tk) + assertTrue(response.coverageScheme.first().contour.startsWith("POLYGON")) + } + + @Test + fun `calculate keeps observation when flight line does not produce polygon`() { + val observation = SquareViewParamDTO( + noradId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5) + ) + val service = buildService( + FakeBallisticsClient( + observations = listOf(observation), + flightLinesBySatellite = mapOf( + 11L to listOf( + flightPoint(LocalDateTime.of(2026, 4, 14, 10, 0), lat = 0.0, lon = 196.0, leftLon = 195.5, rightLon = 196.5) + ) + ), + orbitalPointsBySatellite = emptyMap() + ) + ) + + val response = service.calculate( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))", + satelliteIds = listOf(11L), + timeStart = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10) + ) + ) + + assertEquals(1, response.observations.size) + assertTrue(response.observations.first().intersectionContourWkts.isEmpty()) + assertTrue(response.coverageScheme.isEmpty()) + } + + @Test + fun `calculate preserves inner exclusion zone inside strip contour`() { + val observation = SquareViewParamDTO( + noradId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5) + ) + val service = buildService( + FakeBallisticsClient( + observations = listOf(observation), + flightLinesBySatellite = mapOf( + 11L to listOf( + flightPoint( + LocalDateTime.of(2026, 4, 14, 10, 0), + lat = 0.0, + lon = 196.0, + leftLon = 195.5, + rightLon = 196.5, + innerLeftLon = 195.9, + innerRightLon = 196.1 + ), + flightPoint( + LocalDateTime.of(2026, 4, 14, 10, 5), + lat = 2.0, + lon = 196.0, + leftLon = 195.5, + rightLon = 196.5, + innerLeftLon = 195.9, + innerRightLon = 196.1 + ) + ) + ), + orbitalPointsBySatellite = mapOf(11L to sampleOrbitalPoints()) + ) + ) + + val response = service.calculate( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))", + satelliteIds = listOf(11L), + timeStart = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10) + ) + ) + + val contours = response.observations.first().intersectionContourWkts + assertEquals(2, contours.size) + assertTrue(contours.any { it.contains("195.5 0") && it.contains("195.9 2") }) + assertTrue(contours.any { it.contains("196.1 0") && it.contains("196.5 2") }) + assertEquals(1, response.coverageScheme.size) + } + + @Test + fun `calculate normalizes ballistics flight line to standard band for zero crossing target`() { + val observation = SquareViewParamDTO( + noradId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5), + gammaMin = 0.0, + gammaMax = 0.0 + ) + val service = buildService( + FakeBallisticsClient( + observations = listOf(observation), + flightLinesBySatellite = mapOf( + 11L to listOf( + flightPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + lat = 0.0, + lon = 0.0, + leftLon = 359.5, + rightLon = 0.5 + ), + flightPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 5), + lat = 2.0, + lon = 0.0, + leftLon = 359.5, + rightLon = 0.5 + ) + ) + ), + orbitalPointsBySatellite = mapOf(11L to sampleOrbitalPoints()) + ) + ) + + val response = service.calculate( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "POLYGON ((-1 0, 1 0, 1 2, -1 2, -1 0))", + satelliteIds = listOf(11L), + timeStart = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10) + ) + ) + + assertEquals(1, response.observations.size) + assertEquals(1, response.observations.first().intersectionContourWkts.size) + assertTrue(response.observations.first().intersectionContourWkts.first().contains("-0.5 0")) + assertTrue(response.observations.first().intersectionContourWkts.first().contains("0.5 2")) + } + + @Test + fun `calculate returns observations in stable order even if mplSquare order changes`() { + val first = SquareViewParamDTO( + noradId = 11L, + revolutionBegin = 3L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5), + gammaMin = 1.0, + gammaMax = 3.0 + ) + val second = SquareViewParamDTO( + noradId = 22L, + revolutionBegin = 2L, + timeBegin = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 1), + gammaMin = -1.0, + gammaMax = 2.0 + ) + val service = buildService( + FakeBallisticsClient( + observations = listOf(first, second), + flightLinesBySatellite = emptyMap(), + orbitalPointsBySatellite = emptyMap() + ) + ) + + val response = service.calculate( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))", + satelliteIds = listOf(22L, 11L), + timeStart = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10) + ) + ) + + assertEquals(listOf(11L, 22L), response.observations.map { it.observation.noradId }) + assertEquals( + listOf( + LocalDateTime.of(2026, 4, 14, 10, 0), + LocalDateTime.of(2026, 4, 14, 9, 55) + ), + response.observations.map { it.observation.timeBegin } + ) + } + + private class FakeBallisticsClient( + private val observations: List, + private val flightLinesBySatellite: Map>, + private val orbitalPointsBySatellite: Map> + ) : CoverageBallisticsClient { + override fun mplSquare(req: ObjViewRequestDTO): List = observations + + override fun mplPoint(req: ObjViewRequestDTO): List = emptyList() + + override fun flightLine( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List = flightLinesBySatellite[satelliteId].orEmpty() + .filter { point -> !point.time.isBefore(timeStart) && !point.time.isAfter(timeStop) } + + override fun exactTime( + satelliteId: Long, + request: ExactTimePositionRequestDTO + ): List = orbitalPointsBySatellite[satelliteId].orEmpty() + .filter { point -> !point.time.isBefore(request.time) && !point.time.isAfter(request.timeStop ?: request.time) } + } + + private fun flightPoint( + time: LocalDateTime, + lat: Double, + lon: Double, + leftLon: Double, + rightLon: Double, + innerLeftLon: Double = lon, + innerRightLon: Double = lon + ) = FlightLineDTO( + time = time, + lat = lat, + long = lon, + latLeft = lat, + longLeft = leftLon, + latInnerLeft = lat, + longInnerLeft = innerLeftLon, + latInnerRight = lat, + longInnerRight = innerRightLon, + latRight = lat, + longRight = rightLon + ) + + private fun buildService(fakeClient: FakeBallisticsClient) = CoverageSchemeCalculationService( + ballisticsClient = fakeClient, + coverageSchemeBuilderService = CoverageSchemeBuilderService(fakeClient, FakeSatelliteClient(), geometrySupport), + geometrySupport = geometrySupport, + properties = CoverageSchemeProperties(flightLinePaddingSeconds = 0) + ) + + private class FakeSatelliteClient : CoverageSatelliteClient { + override fun satellite(satelliteId: Long): SatelliteInfoDTO = + SatelliteInfoDTO(noradId = satelliteId, captureAngle = 1.5) + } + + private fun sampleOrbitalPoints() = listOf( + OrbPointDTO( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + revolution = 1L, + vx = -401.529, + vy = 1413.431, + vz = 7547.696, + x = -6603039.949, + y = -1870023.148, + z = 0.0 + ), + OrbPointDTO( + time = LocalDateTime.of(2026, 4, 14, 10, 1), + revolution = 1L, + vx = -420.0, + vy = 1390.0, + vz = 7540.0, + x = -6595000.0, + y = -1860000.0, + z = 452000.0 + ) + ) +} diff --git a/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeBuilderServiceTest.kt b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeBuilderServiceTest.kt new file mode 100644 index 0000000..4a8afe2 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeBuilderServiceTest.kt @@ -0,0 +1,652 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import ballistics.flightLine.PointOnEarthCalculator +import ballistics.types.EarthType +import ballistics.types.Orientation +import ballistics.types.OrbitalPoint +import ballistics.types.WorkCSType +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import org.junit.jupiter.api.Test +import org.locationtech.jts.geom.Geometry +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import java.time.LocalDateTime +import kotlin.math.PI +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoverageSchemeBuilderServiceTest { + + private val geometrySupport = GeometrySupport() + + @Test + fun `buildCoverageScheme uses dominant branch and sorts by left boundary`() { + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) to reverseOrbitalPoints(), + requestKey(22L, LocalDateTime.of(2026, 4, 14, 9, 55)) to reverseOrbitalPoints(), + requestKey(33L, LocalDateTime.of(2026, 4, 14, 10, 2)) to sampleOrbitalPoints() + ) + ), + satelliteClient = FakeSatelliteClient( + mapOf( + 11L to 1.5, + 22L to 1.5, + 33L to 1.5 + ) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5), + branch = RevolutionSign.ASC, + gammaMin = -2.0, + gammaMax = 6.0, + contour = "POLYGON ((195.5 2.0, 196.5 2.0, 196.5 4.2, 195.5 4.2, 195.5 2.0))" + ), + observation( + satelliteId = 22L, + timeBegin = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 1), + branch = RevolutionSign.ASC, + gammaMin = 1.0, + gammaMax = 5.0, + contour = "POLYGON ((195.5 -0.2, 196.5 -0.2, 196.5 2.1, 195.5 2.1, 195.5 -0.2))" + ), + observation( + satelliteId = 33L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 2), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 7), + branch = RevolutionSign.DESC, + gammaMin = 3.0, + gammaMax = 7.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) + ) + ) + + assertEquals(2, result.size) + assertEquals(listOf(22L, 11L), result.map { it.satelliteId }) + assertEquals(5.0, result.first().roll) + assertEquals(6.0, result.last().roll) + assertTrue(result.all { it.revolutionSign == RevolutionSign.ASC }) + } + + @Test + fun `buildCoverageScheme moves overlapping next contour to the right while preserving continuity`() { + val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0) + val mplPointRequests = mutableListOf() + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, requestTime) to reverseOrbitalPoints(), + requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints() + ), + mplPointsBySatellite = mapOf( + 11L to listOf( + PointViewParamDTO( + noradId = 11L, + time = requestTime, + gamma = 6.0 + ) + ), + 22L to listOf( + PointViewParamDTO( + noradId = 22L, + time = requestTime.plusMinutes(10), + gamma = 6.0 + ) + ) + ), + capturedMplPointRequests = mplPointRequests + ), + satelliteClient = FakeSatelliteClient( + mapOf( + 11L to 1.5, + 22L to 1.5 + ) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = requestTime, + timeEnd = requestTime.plusMinutes(5), + branch = RevolutionSign.ASC, + gammaMin = 4.0, + gammaMax = 4.0, + contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))" + ), + observation( + satelliteId = 22L, + timeBegin = requestTime.plusMinutes(10), + timeEnd = requestTime.plusMinutes(15), + branch = RevolutionSign.ASC, + gammaMin = 0.0, + gammaMax = 8.0, + contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))" + ) + ), + rollStepDegrees = 1.0 + ) + + assertEquals(2, result.size) + assertEquals(4.0, result.first().roll) + assertEquals(7.5, result.last().roll) + assertEquals(1, mplPointRequests.size) + + val firstContour = geometrySupport.parseAndNormalizeTarget(result.first().contour) + val secondContour = geometrySupport.parseAndNormalizeTarget(result.last().contour) + assertTrue(secondContour.area > 0.0) + assertTrue(firstContour.area > 0.0) + assertPositionEquals( + expectedRightmostStartPosition(firstContour), + mplPointRequests.single().obj.position + ) + assertTrue(requireNotNull(mplPointRequests.single().obj.position).long in 0.0..360.0) + } + + @Test + fun `buildCoverageScheme uses minimum roll and right-side anchor for desc branch`() { + val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0) + val mplPointRequests = mutableListOf() + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, requestTime) to reverseOrbitalPoints(), + requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints() + ), + mplPointsBySatellite = mapOf( + 22L to listOf( + PointViewParamDTO( + noradId = 22L, + time = requestTime.plusMinutes(10), + gamma = -6.0 + ) + ) + ), + capturedMplPointRequests = mplPointRequests + ), + satelliteClient = FakeSatelliteClient( + mapOf( + 11L to 1.5, + 22L to 1.5 + ) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = requestTime, + timeEnd = requestTime.plusMinutes(5), + branch = RevolutionSign.DESC, + gammaMin = -7.0, + gammaMax = -2.0, + contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))" + ), + observation( + satelliteId = 22L, + timeBegin = requestTime.plusMinutes(10), + timeEnd = requestTime.plusMinutes(15), + branch = RevolutionSign.DESC, + gammaMin = -8.0, + gammaMax = 0.0, + contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))" + ) + ) + ) + + assertEquals(2, result.size) + assertEquals(-7.0, result.first().roll) + assertEquals(-4.5, result.last().roll) + assertEquals(1, mplPointRequests.size) + assertPositionEquals( + expectedRightmostStartPosition(geometrySupport.parseAndNormalizeTarget(result.first().contour)), + mplPointRequests.single().obj.position + ) + assertTrue(requireNotNull(mplPointRequests.single().obj.position).long in 0.0..360.0) + } + + @Test + fun `buildCoverageScheme skips subsequent contour when required sign is unavailable`() { + val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0) + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, requestTime) to reverseOrbitalPoints(), + requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints() + ) + ), + satelliteClient = FakeSatelliteClient( + mapOf( + 11L to 1.5, + 22L to 1.5 + ) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = requestTime, + timeEnd = requestTime.plusMinutes(5), + branch = RevolutionSign.ASC, + gammaMin = 4.0, + gammaMax = 8.0, + contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))" + ), + observation( + satelliteId = 22L, + timeBegin = requestTime.plusMinutes(10), + timeEnd = requestTime.plusMinutes(15), + branch = RevolutionSign.ASC, + gammaMin = -8.0, + gammaMax = -2.0, + contour = "POLYGON ((196.5 -0.1, 197.3 -0.1, 197.3 4.0, 196.5 4.0, 196.5 -0.1))" + ) + ) + ) + + assertEquals(1, result.size) + assertEquals(8.0, result.single().roll) + } + + @Test + fun `buildCoverageScheme splits long observation by satellite max duration and mmi`() { + val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0) + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, start) to reverseOrbitalPoints(), + requestKey(11L, start.plusSeconds(70)) to sampleOrbitalPoints(), + requestKey(11L, start.plusSeconds(140)) to shiftedReverseOrbitalPoints() + ) + ), + satelliteClient = FakeSatelliteClient( + captureAnglesBySatellite = mapOf(11L to 1.5), + maxSurveyDurationSecondsBySatellite = mapOf(11L to 60L), + mmiSecondsBySatellite = mapOf(11L to 10L) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = start, + timeEnd = start.plusSeconds(180), + branch = RevolutionSign.ASC, + gammaMin = 1.0, + gammaMax = 5.0, + contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))" + ) + ) + ) + + assertEquals(3, result.size) + assertEquals(start, result[0].tn) + assertEquals(start.plusSeconds(60), result[0].tk) + assertEquals(start.plusSeconds(70), result[1].tn) + assertEquals(start.plusSeconds(130), result[1].tk) + assertEquals(start.plusSeconds(140), result[2].tn) + assertEquals(start.plusSeconds(180), result[2].tk) + } + + @Test + fun `buildCoverageScheme skips candidate when contour does not intersect required area`() { + val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0) + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, start) to sampleOrbitalPoints() + ) + ), + satelliteClient = FakeSatelliteClient( + captureAnglesBySatellite = mapOf(11L to 1.5) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = start, + timeEnd = start.plusMinutes(5), + branch = RevolutionSign.ASC, + gammaMin = 1.0, + gammaMax = 5.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ) + ) + ) + + assertTrue(result.isEmpty()) + } + + @Test + fun `buildCoverageScheme skips candidate when overlap with existing coverage is above ninety percent`() { + val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0) + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, start) to reverseOrbitalPoints(), + requestKey(22L, start.plusMinutes(10)) to reverseOrbitalPoints() + ) + ), + satelliteClient = FakeSatelliteClient( + captureAnglesBySatellite = mapOf( + 11L to 1.5, + 22L to 1.5 + ) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = start, + timeEnd = start.plusMinutes(5), + branch = RevolutionSign.ASC, + gammaMin = 4.0, + gammaMax = 4.0, + contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))" + ), + observation( + satelliteId = 22L, + timeBegin = start.plusMinutes(10), + timeEnd = start.plusMinutes(15), + branch = RevolutionSign.ASC, + gammaMin = 4.0, + gammaMax = 4.0, + contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))" + ) + ) + ) + + assertEquals(1, result.size) + assertEquals(11L, result.single().satelliteId) + } + + @Test + fun `buildCoverageScheme falls back to minimum roll when mplPoint returns multiple records`() { + val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0) + val service = CoverageSchemeBuilderService( + ballisticsClient = FakeBallisticsClient( + orbitalPointsByRequest = mapOf( + requestKey(11L, requestTime) to reverseOrbitalPoints(), + requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints() + ), + mplPointsBySatellite = mapOf( + 22L to listOf( + PointViewParamDTO(noradId = 22L, time = requestTime.plusMinutes(10), gamma = 4.0), + PointViewParamDTO(noradId = 22L, time = requestTime.plusMinutes(11), gamma = 5.0) + ) + ) + ), + satelliteClient = FakeSatelliteClient( + mapOf( + 11L to 1.5, + 22L to 1.5 + ) + ), + geometrySupport = geometrySupport + ) + + val result = service.buildCoverageScheme( + observations = listOf( + observation( + satelliteId = 11L, + timeBegin = requestTime, + timeEnd = requestTime.plusMinutes(5), + branch = RevolutionSign.ASC, + gammaMin = 4.0, + gammaMax = 4.0, + contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))" + ), + observation( + satelliteId = 22L, + timeBegin = requestTime.plusMinutes(10), + timeEnd = requestTime.plusMinutes(15), + branch = RevolutionSign.ASC, + gammaMin = 0.0, + gammaMax = 8.0, + contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))" + ) + ) + ) + + assertEquals(2, result.size) + assertEquals(8.0, result.last().roll) + } + + private class FakeBallisticsClient( + private val orbitalPointsByRequest: Map, List>, + private val mplPointsBySatellite: Map> = emptyMap(), + private val capturedMplPointRequests: MutableList = mutableListOf() + ) : CoverageBallisticsClient { + override fun mplSquare(req: ObjViewRequestDTO): List = emptyList() + + override fun mplPoint(req: ObjViewRequestDTO): List { + capturedMplPointRequests += req + return req.satellites.singleOrNull()?.let { mplPointsBySatellite[it].orEmpty() } ?: emptyList() + } + + override fun flightLine( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List = emptyList() + + override fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List = + orbitalPointsByRequest[satelliteId to request.time].orEmpty() + } + + private class FakeSatelliteClient( + private val captureAnglesBySatellite: Map, + private val maxSurveyDurationSecondsBySatellite: Map = emptyMap(), + private val mmiSecondsBySatellite: Map = emptyMap() + ) : CoverageSatelliteClient { + override fun satellite(satelliteId: Long): SatelliteInfoDTO = + SatelliteInfoDTO( + noradId = satelliteId, + captureAngle = captureAnglesBySatellite.getValue(satelliteId), + maxSurveyDurationSeconds = maxSurveyDurationSecondsBySatellite[satelliteId] ?: 300L, + mmiSeconds = mmiSecondsBySatellite[satelliteId] ?: 10L + ) + } + + private fun observation( + satelliteId: Long, + timeBegin: LocalDateTime, + timeEnd: LocalDateTime, + branch: RevolutionSign, + gammaMin: Double, + gammaMax: Double, + contour: String + ) = CoverageObservationDTO( + observation = SquareViewParamDTO( + noradId = satelliteId, + revolutionBegin = satelliteId, + timeBegin = timeBegin, + timeEnd = timeEnd, + gammaMin = gammaMin, + gammaMax = gammaMax, + revSignBegin = branch, + revSignEnd = branch + ), + intersectionContourWkts = listOf(contour) + ) + + private fun requestKey(satelliteId: Long, time: LocalDateTime) = satelliteId to time + + private fun expectedBoundaryPosition( + branch: RevolutionSign, + orbitalPoints: List, + roll: Double, + capture: Double + ): PositionDTO { + val firstPoint = orbitalPoints.first().toOrbitalPoint() + val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + val gamma = when (branch) { + RevolutionSign.ASC -> roll - capture + RevolutionSign.DESC -> roll + capture + } + val pointOnEarth = calculator.pointOnEarth(firstPoint, Orientation(0.0, gamma * PI / 180.0, 0.0))!! + return PositionDTO( + lat = pointOnEarth.lat * 180.0 / PI, + long = pointOnEarth.long * 180.0 / PI, + height = 0.0 + ) + } + + private fun expectedRightmostStartPosition(contour: Geometry): PositionDTO { + val coordinates = contour.coordinates + val rightmostStartPoint = listOf( + coordinates.first(), + coordinates[coordinates.size - 2] + ).maxByOrNull { it.x }!! + return PositionDTO( + lat = rightmostStartPoint.y, + long = rightmostStartPoint.x, + height = 0.0 + ) + } + + private fun assertPositionEquals(expected: PositionDTO, actual: PositionDTO?) { + fun normalizeToStandardLongitude(longitude: Double): Double { + var normalized = longitude + while (normalized > 180.0) normalized -= 360.0 + while (normalized <= -180.0) normalized += 360.0 + return normalized + } + + requireNotNull(actual) + assertEquals(expected.lat, actual.lat, 1e-9) + assertEquals(normalizeToStandardLongitude(expected.long), normalizeToStandardLongitude(actual.long), 1e-9) + } + + private fun sampleOrbitalPoints() = listOf( + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + revolution = 42L, + x = -6603039.949, + y = -1870023.148, + z = 0.0, + vx = -401.529, + vy = 1413.431, + vz = 7547.696 + ), + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 1), + revolution = 42L, + x = -6595000.0, + y = -1860000.0, + z = 452000.0, + vx = -420.0, + vy = 1390.0, + vz = 7540.0 + ) + ) + + private fun reverseOrbitalPoints() = listOf( + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + revolution = 42L, + x = -6595000.0, + y = -1860000.0, + z = 452000.0, + vx = 420.0, + vy = -1390.0, + vz = -7540.0 + ), + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 1), + revolution = 42L, + x = -6603039.949, + y = -1870023.148, + z = 0.0, + vx = 401.529, + vy = -1413.431, + vz = -7547.696 + ) + ) + + private fun shiftedReverseOrbitalPoints() = listOf( + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + revolution = 42L, + x = -6495000.0, + y = -1760000.0, + z = 452000.0, + vx = 420.0, + vy = -1390.0, + vz = -7540.0 + ), + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 1), + revolution = 42L, + x = -6503039.949, + y = -1770023.148, + z = 0.0, + vx = 401.529, + vy = -1413.431, + vz = -7547.696 + ) + ) + + private fun orbPoint( + time: LocalDateTime, + revolution: Long, + x: Double, + y: Double, + z: Double, + vx: Double, + vy: Double, + vz: Double + ) = OrbPointDTO( + time = time, + revolution = revolution, + vx = vx, + vy = vy, + vz = vz, + x = x, + y = y, + z = z + ) + + private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint( + t = fromDateTime(time), + vit = revolution.toInt(), + r = Vector3D(x, y, z), + v = Vector3D(vx, vy, vz) + ) +} diff --git a/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeSolverTest.kt b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeSolverTest.kt new file mode 100644 index 0000000..9a5a678 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/CoverageSchemeSolverTest.kt @@ -0,0 +1,111 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient +import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient +import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals + +class CoverageSchemeMultiContourTest { + + private val service = CoverageSchemeCalculationService( + ballisticsClient = FakeBallisticsClient(), + coverageSchemeBuilderService = CoverageSchemeBuilderService(FakeBallisticsClient(), FakeSatelliteClient(), GeometrySupport()), + geometrySupport = GeometrySupport(), + properties = CoverageSchemeProperties(flightLinePaddingSeconds = 0) + ) + + @Test + fun `calculate splits multipolygon intersection into separate contours`() { + val response = service.calculate( + CoverageSchemeCalculateRequestDTO( + polygonWkt = "MULTIPOLYGON (((195 0, 196 0, 196 2, 195 2, 195 0)), ((197 0, 198 0, 198 2, 197 2, 197 0)))", + satelliteIds = listOf(11L), + timeStart = LocalDateTime.of(2026, 4, 14, 9, 55), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10) + ) + ) + + assertEquals(1, response.observations.size) + assertEquals(2, response.observations.first().intersectionContourWkts.size) + assertEquals(1, response.coverageScheme.size) + } + + private class FakeBallisticsClient : CoverageBallisticsClient { + override fun mplSquare(req: ObjViewRequestDTO): List = + listOf( + SquareViewParamDTO( + noradId = 11L, + timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0), + timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5) + ) + ) + + override fun mplPoint(req: ObjViewRequestDTO): List = emptyList() + + override fun flightLine( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List = listOf( + FlightLineDTO( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + lat = 0.0, + long = 196.5, + latLeft = 0.0, + longLeft = 195.5, + latRight = 0.0, + longRight = 197.5 + ), + FlightLineDTO( + time = LocalDateTime.of(2026, 4, 14, 10, 5), + lat = 2.0, + long = 196.5, + latLeft = 2.0, + longLeft = 195.5, + latRight = 2.0, + longRight = 197.5 + ) + ) + + override fun exactTime( + satelliteId: Long, + request: ExactTimePositionRequestDTO + ): List = listOf( + OrbPointDTO( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + revolution = 1L, + vx = -401.529, + vy = 1413.431, + vz = 7547.696, + x = -6603039.949, + y = -1870023.148, + z = 0.0 + ), + OrbPointDTO( + time = LocalDateTime.of(2026, 4, 14, 10, 1), + revolution = 1L, + vx = -420.0, + vy = 1390.0, + vz = 7540.0, + x = -6595000.0, + y = -1860000.0, + z = 452000.0 + ) + ) + } + + private class FakeSatelliteClient : CoverageSatelliteClient { + override fun satellite(satelliteId: Long): SatelliteInfoDTO = + SatelliteInfoDTO(noradId = satelliteId, captureAngle = 1.5) + } +} diff --git a/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/GeometrySupportTest.kt b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/GeometrySupportTest.kt new file mode 100644 index 0000000..aad4006 --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/test/kotlin/space/nstart/pcp/pcp_coverage_scheme_service/service/GeometrySupportTest.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp.pcp_coverage_scheme_service.service + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GeometrySupportTest { + + private val geometrySupport = GeometrySupport() + + @Test + fun `resolveLongitudeModel uses standard band when polygon crosses zero meridian`() { + val model = geometrySupport.resolveLongitudeModel( + "POLYGON ((-1 0, 1 0, 1 1, -1 1, -1 0))" + ) + + assertEquals(GeometrySupport.LongitudeModel.STANDARD_180, model) + } + + @Test + fun `resolveLongitudeModel uses positive band when polygon crosses one hundred eighty meridian`() { + val model = geometrySupport.resolveLongitudeModel( + "POLYGON ((179 0, -179 0, -179 1, 179 1, 179 0))" + ) + + assertEquals(GeometrySupport.LongitudeModel.POSITIVE_360, model) + } + + @Test + fun `parseAndNormalizeTarget keeps zero crossing polygon in standard band`() { + val geometry = geometrySupport.parseAndNormalizeTarget( + "POLYGON ((-1 0, 1 0, 1 1, -1 1, -1 0))", + GeometrySupport.LongitudeModel.STANDARD_180 + ) + + assertTrue(geometry.coordinates.all { it.x in -180.0..180.0 }) + } +} diff --git a/services/pcp-coverage-scheme-service/src/test/resources/application.yaml b/services/pcp-coverage-scheme-service/src/test/resources/application.yaml new file mode 100644 index 0000000..33a882b --- /dev/null +++ b/services/pcp-coverage-scheme-service/src/test/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + config: + import: "" + cloud: + config: + enabled: false + import-check: + enabled: false + boot: + admin: + client: + enabled: false diff --git a/services/pcp-dynamic-plan-service/Dockerfile b/services/pcp-dynamic-plan-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-dynamic-plan-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-dynamic-plan-service/build.gradle.kts b/services/pcp-dynamic-plan-service/build.gradle.kts new file mode 100644 index 0000000..faaf344 --- /dev/null +++ b/services/pcp-dynamic-plan-service/build.gradle.kts @@ -0,0 +1,57 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + + +version = "1.0.0" +description = "Комплексный план. Динамические слоты" + + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation(project(":libs:ballistics-lib")) + + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.locationtech.jts:jts-core:1.19.0") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/ComPlan.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/ComPlan.kt new file mode 100644 index 0000000..18b9cbf --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/ComPlan.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.complan + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + + +@SpringBootApplication +class ComPlan + + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/ComPlanController.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/ComPlanController.kt new file mode 100644 index 0000000..423c14f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/ComPlanController.kt @@ -0,0 +1,136 @@ +package space.nstart.pcp.complan + +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.complan.apl.BasicPlanCalculator +import space.nstart.pcp.complan.service.DynamicPlanRunService +import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO +import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO +import space.nstart.pcp.complan.types.DynamicPlanIntervalDTO +import space.nstart.pcp.complan.types.DynamicPlanRouteDTO +import space.nstart.pcp.complan.types.DynamicPlanRunDTO +import space.nstart.pcp.complan.types.DynamicPlanRunStatus +import space.nstart.pcp.complan.types.GreedyMathDebugRequestDTO +import space.nstart.pcp.complan.types.GreedyMathDebugResultDTO +import space.nstart.pcp.complan.types.ObservationParametersDebugRequestDTO +import space.nstart.pcp.complan.types.ObservationParametersDebugResultDTO +import space.nstart.pcp.complan.types.TestCalcMarsDebugRequestDTO +import space.nstart.pcp.complan.types.TestCalcMarsDebugResultDTO +import java.nio.file.NoSuchFileException +import java.util.UUID + +@RestController +@RequestMapping("/v1/main") +@Tag(name = "Расчет комплексного плана", description = "API расчета комплексного плана") +class ComPlanController( + private val dynamicPlanRunService: DynamicPlanRunService, + private val basicPlanCalculator: BasicPlanCalculator +) { + + @PostMapping("/calcPlan") + @ResponseStatus(HttpStatus.ACCEPTED) + fun calcPlan( + @RequestBody request: ComplexPlanCalculationRequestDTO + ): DynamicPlanRunDTO = dynamicPlanRunService.startCalculation(request) + + @GetMapping("/calcPlan/runs") + fun getCalculations( + @RequestParam(required = false) status: DynamicPlanRunStatus?, + @RequestParam(defaultValue = "50") limit: Int, + @RequestParam(defaultValue = "0") offset: Int + ): List = dynamicPlanRunService.listRuns(status, limit, offset) + + @GetMapping("/calcPlan/{runId}") + fun getCalculation( + @PathVariable runId: UUID + ): DynamicPlanRunDTO = dynamicPlanRunService.getRun(runId) + + @GetMapping("/calcPlan/{runId}/result") + fun getCalculationResult( + @PathVariable runId: UUID + ): ComplexPlanCalculationResultDTO = dynamicPlanRunService.getResult(runId) + + @GetMapping("/calcPlan/{runId}/routes") + fun getCalculationRoutes( + @PathVariable runId: UUID, + @RequestParam(defaultValue = "1000") limit: Int, + @RequestParam(defaultValue = "0") offset: Int + ): List = dynamicPlanRunService.getRoutes(runId, limit, offset) + + @GetMapping("/calcPlan/{runId}/intervals") + fun getCalculationIntervals( + @PathVariable runId: UUID, + @RequestParam(defaultValue = "1000") limit: Int, + @RequestParam(defaultValue = "0") offset: Int + ): List = dynamicPlanRunService.getIntervals(runId, limit, offset) + + @PostMapping("/calcPlan/debug/observation-parameters") + fun debugObservationParameters( + @RequestBody request: ObservationParametersDebugRequestDTO + ): ObservationParametersDebugResultDTO { + if (request.calculationEnd <= request.calculationStart) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart") + } + if (request.satelliteIds.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty") + } + if (request.chunkSize != null && request.chunkSize < 1) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "chunkSize must be greater than or equal to 1") + } + if (request.parallelism != null && request.parallelism < 1) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "parallelism must be greater than or equal to 1") + } + return basicPlanCalculator.calculateObservationParametersDebug(request) + } + + @PostMapping("/calcPlan/debug/greedy-math") + fun debugGreedyMath( + @RequestBody request: GreedyMathDebugRequestDTO + ): GreedyMathDebugResultDTO { + if (request.calculationEnd <= request.calculationStart) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart") + } + if (request.satelliteIds.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty") + } + return try { + basicPlanCalculator.calculateGreedyMathDebug(request) + } catch (error: NoSuchFileException) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "observation parameters file not found: ${error.file}", + error + ) + } + } + + @PostMapping("/calcPlan/debug/testcalcMars") + fun testCalcMars( + @RequestBody request: TestCalcMarsDebugRequestDTO + ): TestCalcMarsDebugResultDTO { + if (request.calculationEnd <= request.calculationStart) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart") + } + if (request.satelliteIds.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty") + } + return try { + basicPlanCalculator.calculateTestCalcMarsDebug(request) + } catch (error: NoSuchFileException) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "observation parameters file not found: ${error.file}", + error + ) + } + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt new file mode 100644 index 0000000..eb0667d --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.complan + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class JacksonConfig { + + @Bean + fun objectMapper(): ObjectMapper { + val mapper = ObjectMapper() + + // Настраиваем формат для LocalDateTime +// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz") + +// module.addSerializer( +// LocalDateTime::class.java, +// LocalDateTimeSerializer(formatter) +// ) +// module.addDeserializer( +// LocalDateTime::class.java, +// LocalDateTimeDeserializer(formatter) +// ) + mapper.registerModule(JavaTimeModule()) + .registerKotlinModule() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + return mapper + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt new file mode 100644 index 0000000..2bdcfff --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt @@ -0,0 +1,1985 @@ +package space.nstart.pcp.complan.apl + + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.LineString +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import space.nstart.pcp.complan.service.BallTaskServce +import space.nstart.pcp.complan.service.RequestServiceClient +import space.nstart.pcp.complan.service.SatelliteCatalogServiceClient +import space.nstart.pcp.complan.settings.AplSettings +import space.nstart.pcp.complan.spacecraft.SCConstraints +import space.nstart.pcp.complan.spacecraft.SpaceCraft +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import space.nstart.pcp.complan.types.AplObjItem +import space.nstart.pcp.complan.types.ComplexPlanCalculationOutput +import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO +import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO +import space.nstart.pcp.complan.types.ComplexPlanCalculationStatus +import space.nstart.pcp.complan.types.ContourRequestDTO +import space.nstart.pcp.complan.types.DynamicPlanCalculationMode +import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode +import space.nstart.pcp.complan.types.GeoJsonItem +import space.nstart.pcp.complan.types.GreedyMathCandidateDTO +import space.nstart.pcp.complan.types.GreedyMathDebugRequestDTO +import space.nstart.pcp.complan.types.GreedyMathDebugResultDTO +import space.nstart.pcp.complan.types.IntervalItemDTO +import space.nstart.pcp.complan.types.ObjDTO +import space.nstart.pcp.complan.types.ObjViewRequestDTO +import space.nstart.pcp.complan.types.ObservationParametersDebugFileDTO +import space.nstart.pcp.complan.types.ObservationParametersDebugRequestDTO +import space.nstart.pcp.complan.types.ObservationParametersDebugResultDTO +import space.nstart.pcp.complan.types.RequestItemDTO +import space.nstart.pcp.complan.types.SurveyContour +import space.nstart.pcp.complan.types.TestCalcMarsDebugRequestDTO +import space.nstart.pcp.complan.types.TestCalcMarsDebugResultDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +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.SquareViewParamDTO +import space.nstart.pcp.complan.utils.extensions.toInt +import space.nstart.pcp.complan.settings.SettingsDTO +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import kotlin.collections.get +import kotlin.compareTo +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToLong +import kotlin.math.sin + +private const val INTERVAL_COVERAGE_AREA_TOLERANCE = 1.0e-9 +private const val INTERVAL_ROUTE_WIDTH_RATIO = 0.9 +private const val EARTH_RADIUS_KM = 6371.0088 +private const val ROUTE_COVERAGE_AREA_TOLERANCE_KM2 = 1.0e-6 + +private data class PlanBuildResult( + val routes: List +) + +private data class GreedyMathRouteBuildResult( + val routes: List, + val failuresCount: Int +) + +private data class GreedyMathBuiltRoute( + val candidate: GreedyRouteCandidate, + val route: SurveyContour +) + +private data class GreedyMathUsefulAreaResult( + val routes: List, + val beforeCount: Int, + val afterCount: Int +) + +private data class TestCalcMarsRouteBuildResult( + val attemptsCount: Int, + val routesCount: Int, + val failuresCount: Int +) + +private data class MplCalculationChunkResult( + val intervals: List, + val mpl: List +) + +private enum class CalculationTimingStage { + INTERVAL_BUILD, + OBSERVATION_PARAMETERS, + GREEDY_SELECTION, + ROUTE_BUILD, + USEFUL_AREA_ANALYSIS +} + +/** + * Накопитель замеров одного запуска расчета. + * Один stage может вызываться несколько раз: например, интервалы строятся отдельно для восходящего и + * нисходящего витка. Поэтому здесь храним суммарное время по смысловым этапам, а не последний вызов. + */ +private class CalculationTimings { + private val durationsNanos = linkedMapOf() + + fun measure(stage: CalculationTimingStage, block: () -> T): T { + val startedAt = System.nanoTime() + try { + return block() + } finally { + val durationNanos = System.nanoTime() - startedAt + durationsNanos[stage] = durationsNanos.getOrDefault(stage, 0L) + durationNanos + } + } + + fun millis(stage: CalculationTimingStage): Long = + Duration.ofNanos(durationsNanos.getOrDefault(stage, 0L)).toMillis() +} + +private fun CalculationTimings?.measure(stage: CalculationTimingStage, block: () -> T): T = + this?.measure(stage, block) ?: block() + +/** + * Причина, по которой greedy-путь не смог закрыть конкретный интервал съемки. + * Разделение причин важно для оператора: отсутствие МПЛ требует пересчета/расширения баллистики, + * а лимиты и конфликты означают, что наблюдения есть, но расписание КА уже исчерпало ресурс. + */ +internal enum class GreedyIntervalFailureReason { + NO_MPL, + FILTERED_OUT, + REVOLUTION_LIMIT_EXCEEDED, + DAILY_LIMIT_EXCEEDED, + TIME_CONFLICT, + CONTOUR_BUILD_FAILED, + NO_FEASIBLE_CANDIDATE +} + +/** + * Диагностическая запись по одному незакрытому интервалу. + * Хранит и технический intervalId, и regionId исходной заявки, чтобы можно было связать отказ + * как с построенными интервалами, так и с объектом наблюдения. + */ +internal data class GreedyIntervalFailure( + val intervalId: UUID, + val regionId: UUID, + val reason: GreedyIntervalFailureReason, + val details: String +) + +/** + * Нормализованное представление одного МПЛ-варианта как кандидата на включение. + * Здесь заранее фиксируются ключи учета ресурса КА: satelliteId, revolution и календарные сутки. + * Это отделяет принятие решения по расписанию от исходного DTO баллистики. + */ +internal data class GreedyRouteCandidate( + val intervalId: UUID, + val regionId: UUID, + val mpl: SquareViewParamDTO, + val satelliteId: Long, + val revolution: Long, + val day: LocalDate, + val timeBegin: LocalDateTime, + val timeEnd: LocalDateTime, + val durationSeconds: Long, + val gamma: Double +) + +/** + * Снимок загрузки одного аппарата после greedy-выбора. + * Используется для объяснения результата: какие маршруты заняли ресурс, сколько секунд уже + * израсходовано на каждом витке и в каждые сутки. + */ +internal data class GreedySatelliteWorkload( + val satelliteId: Long, + val revolutionUsageSeconds: Map, + val dailyUsageSeconds: Map, + val routes: List +) + +/** + * Полный результат второго пути: построенные маршруты, незакрытые интервалы с причинами + * и загрузка аппаратов. Старый путь возвращает только маршруты; greedy-путь дополнительно + * обязан объяснять, почему не все интервалы удалось закрыть. + */ +internal data class GreedyPlanResult( + val routes: List, + val failures: List, + val workloads: List +) { + val completedIntervals: Int = routes.size + val failedIntervals: Int = failures.size +} + +/** + * Промежуточный результат выбора кандидатов до построения геометрии SurveyContour наружу. + * Он нужен, чтобы чисто тестировать планировщик без HTTP-запросов в ballistics за точками орбиты. + */ +internal data class GreedySelectionResult( + val selectedCandidates: List, + val failures: List, + val workloads: List +) + +/** + * Сводка покрытия заявки итоговыми маршрутами greedy-расчета. + * `lastRouteStart/End` показывают фактический последний по времени маршрут, а процент покрытия считается + * по пересечению объединения маршрутов с геометрией заявки. + */ +internal data class RouteCoverageSummary( + val lastRouteStart: LocalDateTime?, + val lastRouteEnd: LocalDateTime?, + val coveredAreaPercent: Double? +) + +/** + * Результат сверки запрошенной группировки с каталогом КА. + * Сервис продолжает расчет по найденным аппаратам, но явно возвращает/логирует отсутствующие, + * чтобы клиент видел, почему часть группировки не попала в план. + */ +internal data class SatelliteSelection( + val existingIds: List, + val missingIds: List +) + +/** + * Отбирает только те КА из запроса, которые реально есть в satellite-catalog-service. + * Порядок найденных аппаратов сохраняется по входному запросу: он дальше влияет на порядок загрузки трасс + * и диагностические сообщения, но не подменяет состав группировки. + */ +internal fun selectSatellitesForCalculation( + requestedSatelliteIds: Collection, + catalogSatelliteIds: Collection +): SatelliteSelection { + val catalogIds = catalogSatelliteIds.toSet() + val requestedIds = requestedSatelliteIds.distinct() + return SatelliteSelection( + existingIds = requestedIds.filter { it in catalogIds }, + missingIds = requestedIds.filterNot { it in catalogIds } + ) +} + +/** + * Общая точка отбора МПЛ-вариантов для обоих путей построения маршрутов. + * Сначала варианты упорядочиваются по минимальному модулю крена, чтобы старый путь 1 и новый greedy-путь + * одинаково предпочитали более "дешевые" наблюдения. Затем применяются пользовательские фильтры gamma/revolution. + */ +internal fun selectMplItemsForRouteBuild( + mplItems: List, + settings: SettingsDTO +): List = + mplItems + .sortedBy { abs(it.gammaMin) } + .filter { mplItem -> + val passesGamma = !settings.filterByGamma || + abs((mplItem.gammaMin + mplItem.gammaMax) / 2) <= settings.gammaValue + val passesRevolution = !settings.filterByRevolution || + mplItem.revSignBegin.toInt() == settings.revolutionType + passesGamma && passesRevolution + } + +internal fun revolutionSignsForIntervals(revolutionMode: DynamicPlanRevolutionMode): List = + when (revolutionMode) { + DynamicPlanRevolutionMode.ASC -> listOf(RevolutionSign.ASC) + DynamicPlanRevolutionMode.DESC -> listOf(RevolutionSign.DESC) + DynamicPlanRevolutionMode.BOTH -> listOf(RevolutionSign.DESC, RevolutionSign.ASC) + } + +/** + * Фильтр маршрутов без количественного порога полезной площади. + * Идем от ранних маршрутов к поздним и держим остаток геометрии заявки. Маршрут остается, если его контур + * имеет ненулевое пересечение по площади с еще не покрытой частью заявки; после этого его контур вырезается + * из остатка. + */ +internal fun filterRoutesByRemainingRequestGeometry( + routes: List, + requestGeometry: Geometry +): List { + if (routes.isEmpty() || requestGeometry.isEmpty) { + return routes + } + + val wktReader = WKTReader() + var remainingRequestGeometry = requestGeometry + val keptRoutes = mutableListOf() + routes + .sortedWith( + compareBy { it.time } + .thenBy { it.duration } + .thenBy { it.mpl.noradId } + ) + .forEach { route -> + if (remainingRequestGeometry.isEmpty) { + return@forEach + } + + val routeGeometry = route.geometry.toRouteGeometry(wktReader) + if (routeGeometry == null) { + keptRoutes += route + return@forEach + } + + val hasUsefulCoverage = runCatching { + routeGeometry.intersection(remainingRequestGeometry).area > 0.0 + }.getOrDefault(true) + if (!hasUsefulCoverage) { + return@forEach + } + + keptRoutes += route + remainingRequestGeometry = remainingRequestGeometry.removeCoverage(routeGeometry) + } + + return keptRoutes +} + +private fun String.toRouteGeometry(wktReader: WKTReader): Geometry? = + runCatching { wktReader.read(this).takeUnless { it.isEmpty } }.getOrNull() + +private fun Geometry.removeCoverage(routeGeometry: Geometry): Geometry = + runCatching { difference(routeGeometry) }.getOrDefault(this) + +private fun Geometry.removeCoverage(routeGeometries: List): Geometry { + val coverage = routeGeometries.unionGeometry() ?: return this + return removeCoverage(coverage) +} + +private fun List.unionGeometry(): Geometry? = + reduceOrNull { coverage, geometry -> coverage.addCoverage(geometry) } + +internal fun summarizeRouteCoverage( + routes: List, + requestGeometry: Geometry +): RouteCoverageSummary { + if (routes.isEmpty() || requestGeometry.isEmpty) { + return RouteCoverageSummary( + lastRouteStart = null, + lastRouteEnd = null, + coveredAreaPercent = 0.0 + ) + } + + val lastRoute = routes.maxWithOrNull( + compareBy { it.time } + .thenBy { it.duration } + .thenBy { it.mpl.noradId } + ) + val routeUnion = WKTReader().let { wktReader -> + routes + .mapNotNull { it.geometry.toRouteGeometry(wktReader) } + .unionGeometry() + } + val coveragePercent = runCatching { + val requestAreaKm2 = requestGeometry.areaSquareKilometers() + if (requestAreaKm2 <= ROUTE_COVERAGE_AREA_TOLERANCE_KM2 || routeUnion == null) { + 0.0 + } else { + val coveredAreaKm2 = routeUnion.intersection(requestGeometry).areaSquareKilometers() + (coveredAreaKm2 / requestAreaKm2 * 100.0).coerceIn(0.0, 100.0) + } + }.getOrNull() + + return RouteCoverageSummary( + lastRouteStart = lastRoute?.time, + lastRouteEnd = lastRoute?.endTime(), + coveredAreaPercent = coveragePercent + ) +} + +private fun SurveyContour.endTime(): LocalDateTime = + time.plusNanos((duration * 1_000_000_000.0).roundToLong().coerceAtLeast(1L)) + +internal fun Geometry.areaSquareKilometers(): Double = + when (geometryType) { + "Polygon" -> (this as Polygon).areaSquareKilometers() + "MultiPolygon", "GeometryCollection" -> (0 until numGeometries).sumOf { getGeometryN(it).areaSquareKilometers() } + else -> 0.0 + } + +private fun Polygon.areaSquareKilometers(): Double { + val shellArea = abs(exteriorRing.coordinates.ringAreaSquareKilometers()) + val holesArea = (0 until numInteriorRing).sumOf { index -> + abs(getInteriorRingN(index).coordinates.ringAreaSquareKilometers()) + } + return max(shellArea - holesArea, 0.0) +} + +private fun Array.ringAreaSquareKilometers(): Double { + if (size < 4) { + return 0.0 + } + + var sum = 0.0 + for (index in 0 until size - 1) { + val current = this[index] + val next = this[index + 1] + val lat1 = current.y.toRadians() + val lat2 = next.y.toRadians() + val deltaLon = normalizeLongitudeDelta(next.x.toRadians() - current.x.toRadians()) + sum += deltaLon * (2.0 + sin(lat1) + sin(lat2)) + } + return sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM / 2.0 +} + +private fun Double.toRadians(): Double = this * PI / 180.0 + +private fun normalizeLongitudeDelta(value: Double): Double = + when { + value > PI -> value - 2.0 * PI + value < -PI -> value + 2.0 * PI + else -> value + } + +private fun Geometry?.addCoverage(routeGeometry: Geometry): Geometry { + val currentCoverage = this ?: return routeGeometry + if (currentCoverage.isEmpty) { + return routeGeometry + } + return runCatching { currentCoverage.union(routeGeometry) }.getOrDefault(currentCoverage) +} + +/** + * Чистая часть greedy-планировщика: на вход получает уже рассчитанные интервалы с МПЛ, + * а на выходе возвращает выбранные включения, отказы и загрузку КА. + * + * Функция не ходит во внешние сервисы напрямую. Проверка `contourAvailable` передается извне, + * чтобы production-код мог подтвердить построение контура, а unit-тесты могли проверять планирование + * без зависимости от ballistics-service. Ограничения берутся из `SpaceCraft.constraints`; если КА не передан, + * используется `defaultConstraints`, что сохраняет простой вызов для тестов и fallback-сценариев. + */ +internal fun selectGreedyRouteCandidates( + intervals: List, + settings: SettingsDTO, + spacecraft: Collection = emptyList(), + defaultConstraints: SCConstraints = SCConstraints(), + revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + contourAvailable: (GreedyRouteCandidate) -> Boolean = { true } +): GreedySelectionResult { + val workload = GreedyWorkloadState( + constraintsBySatellite = spacecraft.associate { it.id to it.constraints }, + defaultConstraints = defaultConstraints + ) + val selectedCandidates = mutableListOf() + val failures = mutableListOf() + + // Greedy-путь намеренно дает приоритет наблюдениям с минимальным модулем крена. + // Это простое расписание, а не глобальный оптимизатор: первый принятый маршрут закрепляет ресурс КА + // и влияет на следующие интервалы. + intervals + .filter { it.matches(revolutionMode) } + .sortedWith( + compareBy { it.minGreedyRollAngle(settings) } + .thenBy { interval -> interval.mpl.minOfOrNull { it.timeBegin } ?: LocalDateTime.MAX } + .thenBy { it.id.toString() } + ) + .forEach { interval -> + if (interval.mpl.isEmpty()) { + failures += interval.failure(GreedyIntervalFailureReason.NO_MPL, "Для интервала нет наблюдений в матрице планирования") + return@forEach + } + + val candidates = interval.toGreedyCandidates(settings) + if (candidates.isEmpty()) { + failures += interval.failure(GreedyIntervalFailureReason.FILTERED_OUT, "Все наблюдения отфильтрованы настройками gamma/revolution") + return@forEach + } + + val candidateFailures = mutableListOf() + val accepted = candidates + .sortedWith(workload.greedyCandidateComparator()) + .firstOrNull { candidate -> + // Контур строим только после проверки ресурсов. Так не тратим дорогой вызов ballistics + // для кандидатов, которые заведомо не могут войти в расписание. + val rejectionReason = workload.rejectionReason(candidate) + if (rejectionReason != null) { + candidateFailures += rejectionReason + return@firstOrNull false + } + if (!contourAvailable(candidate)) { + candidateFailures += GreedyIntervalFailureReason.CONTOUR_BUILD_FAILED + return@firstOrNull false + } + true + } + + if (accepted == null) { + failures += interval.failure(candidateFailures) + } else { + workload.add(accepted) + selectedCandidates += accepted + } + } + + return GreedySelectionResult( + selectedCandidates = selectedCandidates, + failures = failures, + workloads = workload.snapshot() + ) +} + +/** + * Преобразует MPL-ответы баллистики в кандидатов greedy-планировщика. + * Если баллистика вернула нулевой или отрицательный интервал времени, оставляем минимальную + * длительность 1 секунда, чтобы такой кандидат не исчезал из расписания бесследно. + */ +private fun IntervalItemDTO.toGreedyCandidates(settings: SettingsDTO): List = + selectMplItemsForRouteBuild(mpl, settings).map { mplItem -> + val durationSeconds = Duration.between(mplItem.timeBegin, mplItem.timeEnd) + .seconds + .coerceAtLeast(1L) + GreedyRouteCandidate( + intervalId = id, + regionId = regionId, + mpl = mplItem, + satelliteId = mplItem.noradId, + revolution = mplItem.revolutionBegin, + day = mplItem.timeBegin.toLocalDate(), + timeBegin = mplItem.timeBegin, + timeEnd = mplItem.timeBegin.plusSeconds(durationSeconds), + durationSeconds = durationSeconds, + gamma = (mplItem.gammaMax + mplItem.gammaMin) / 2 + ) + } + +private fun IntervalItemDTO.minGreedyRollAngle(settings: SettingsDTO): Double = + toGreedyCandidates(settings).minOfOrNull { abs(it.gamma) } ?: Double.MAX_VALUE + +private fun IntervalItemDTO.matches(revolutionMode: DynamicPlanRevolutionMode): Boolean = + when (revolutionMode) { + DynamicPlanRevolutionMode.BOTH -> true + DynamicPlanRevolutionMode.ASC -> revSign == RevolutionSign.ASC + DynamicPlanRevolutionMode.DESC -> revSign == RevolutionSign.DESC + } + +private fun IntervalItemDTO.failure( + reason: GreedyIntervalFailureReason, + details: String +) = GreedyIntervalFailure( + intervalId = id, + regionId = regionId, + reason = reason, + details = details +) + +private fun IntervalItemDTO.failure(reasons: List): GreedyIntervalFailure { + val distinctReasons = reasons.distinct() + val reason = when (distinctReasons.size) { + 0 -> GreedyIntervalFailureReason.NO_FEASIBLE_CANDIDATE + 1 -> distinctReasons.single() + else -> GreedyIntervalFailureReason.NO_FEASIBLE_CANDIDATE + } + val details = if (distinctReasons.isEmpty()) { + "Не найден подходящий кандидат маршрута" + } else { + "Причины отклонения кандидатов: ${distinctReasons.joinToString(", ")}" + } + return failure(reason, details) +} + +private fun GreedyRouteCandidate.toDebugDto(): GreedyMathCandidateDTO = + GreedyMathCandidateDTO( + intervalId = intervalId, + regionId = regionId, + satelliteId = satelliteId, + revolution = revolution, + day = day, + timeBegin = timeBegin, + timeEnd = timeEnd, + durationSeconds = durationSeconds, + gamma = gamma + ) + +internal fun filterIntervalsForGreedyMathFile( + intervals: List, + selectedSatelliteIds: Collection, + calculationStart: LocalDateTime, + calculationEnd: LocalDateTime +): List { + val selectedSatelliteIds = selectedSatelliteIds.toSet() + return intervals.map { interval -> + interval.copy( + mpl = interval.mpl + .filter { mpl -> + mpl.noradId in selectedSatelliteIds && + !mpl.timeBegin.isBefore(calculationStart) && + !mpl.timeEnd.isAfter(calculationEnd) + } + .toMutableList() + ) + } +} + +/** + * Mutable-состояние greedy-расписания на время одного расчета. + * Класс инкапсулирует все проверки загрузки аппарата, чтобы основной проход по интервалам + * оставался читаемым: выбрать кандидата, спросить state о причине отказа, принять или отклонить. + * Временные лимиты не зашиты здесь: state читает их из SCConstraints конкретного аппарата. + */ +private class GreedyWorkloadState( + private val constraintsBySatellite: Map, + private val defaultConstraints: SCConstraints +) { + private val routesBySatellite = linkedMapOf>() + private val revolutionUsage = linkedMapOf, Long>() + private val dailyUsage = linkedMapOf, Long>() + + // Внутри одного интервала сначала пробуем включения с меньшим модулем крена, затем менее загруженный КА. + // Время остается tie-breaker, чтобы одинаковые по крену варианты сохраняли предсказуемый порядок. + fun greedyCandidateComparator(): Comparator = + compareBy { abs(it.gamma) } + .thenBy { revolutionUsageSeconds(it) } + .thenBy { dailyUsageSeconds(it) } + .thenBy { it.timeBegin } + .thenBy { it.satelliteId } + + fun rejectionReason(candidate: GreedyRouteCandidate): GreedyIntervalFailureReason? { + val constraints = constraintsFor(candidate) + if (revolutionUsageSeconds(candidate) + candidate.durationSeconds > constraints.maxRevolutionDurationSeconds) { + return GreedyIntervalFailureReason.REVOLUTION_LIMIT_EXCEEDED + } + if (dailyUsageSeconds(candidate) + candidate.durationSeconds > constraints.maxDailyDurationSeconds) { + return GreedyIntervalFailureReason.DAILY_LIMIT_EXCEEDED + } + if (hasTimeConflict(candidate)) { + return GreedyIntervalFailureReason.TIME_CONFLICT + } + return null + } + + fun add(candidate: GreedyRouteCandidate) { + routesBySatellite.getOrPut(candidate.satelliteId) { mutableListOf() } += candidate + val revolutionKey = candidate.satelliteId to candidate.revolution + revolutionUsage[revolutionKey] = revolutionUsage.getOrDefault(revolutionKey, 0L) + candidate.durationSeconds + val dayKey = candidate.satelliteId to candidate.day + dailyUsage[dayKey] = dailyUsage.getOrDefault(dayKey, 0L) + candidate.durationSeconds + } + + fun snapshot(): List = + routesBySatellite.entries + .sortedBy { it.key } + .map { (satelliteId, routes) -> + GreedySatelliteWorkload( + satelliteId = satelliteId, + revolutionUsageSeconds = revolutionUsage + .filterKeys { it.first == satelliteId } + .mapKeys { it.key.second } + .toSortedMap(), + dailyUsageSeconds = dailyUsage + .filterKeys { it.first == satelliteId } + .mapKeys { it.key.second } + .toSortedMap(), + routes = routes.sortedBy { it.timeBegin } + ) + } + + private fun revolutionUsageSeconds(candidate: GreedyRouteCandidate): Long = + revolutionUsage.getOrDefault(candidate.satelliteId to candidate.revolution, 0L) + + private fun dailyUsageSeconds(candidate: GreedyRouteCandidate): Long = + dailyUsage.getOrDefault(candidate.satelliteId to candidate.day, 0L) + + private fun constraintsFor(candidate: GreedyRouteCandidate): SCConstraints = + constraintsBySatellite[candidate.satelliteId] ?: defaultConstraints + + // Маршруты одного аппарата не должны пересекаться даже частично; технологический разрыв 60 секунд + // учитывается с обеих сторон уже выбранного маршрута. + private fun hasTimeConflict(candidate: GreedyRouteCandidate): Boolean { + val routeGapSeconds = constraintsFor(candidate).routeGapSeconds + return routesBySatellite[candidate.satelliteId].orEmpty().any { existing -> + candidate.timeBegin < existing.timeEnd.plusSeconds(routeGapSeconds) && + candidate.timeEnd.plusSeconds(routeGapSeconds) > existing.timeBegin + } + } +} + +/** + * Application-service расчета комплексного плана. + * + * Класс отвечает за orchestration полного сценария: загрузить заявку и КА, запросить баллистические данные, + * построить интервалы, рассчитать матрицу планирования и передать выбранные включения в построитель контуров. + * Геометрические и баллистические расчеты делегируются специализированным сервисам; внутри класса остаются + * правила выбора маршрутов и сборка результата расчета. + */ +@Service +class BasicPlanCalculator( + /**Заявки для расчета комплексного плана*/ + private val catalog : MutableList, + + private val aplCatalog : MutableList, + /**Группировка КА (SpaceCraftGroup)*/ + private val scg : MutableList +) { + /** Логирование*/ + private val log = LoggerFactory.getLogger(this::class.java) + private val captureAngleCalculator = CaptureAngleCalculator() + + private var writeLog : Boolean = false + private var activeCalculationStart: LocalDateTime? = null + private var activeCalculationEnd: LocalDateTime? = null + + @Value("\${complex-plan.interval-coverage-check-enabled:false}") + private var intervalCoverageCheckEnabled: Boolean = false + + @Value("\${complex-plan.observation-parameters.parallel-enabled:false}") + private var parallelObservationParametersEnabled: Boolean = false + + @Value("\${complex-plan.observation-parameters.chunk-size:50}") + private var observationParametersChunkSize: Int = 50 + + @Value("\${complex-plan.observation-parameters.parallelism:4}") + private var observationParametersParallelism: Int = 4 + + @Autowired + private lateinit var ballTask: BallTaskServce + + @Autowired + private lateinit var surveyContourBuilder: SurveyContourBuilder + + @Autowired + private lateinit var requestServiceClient: RequestServiceClient + + @Autowired + private lateinit var satelliteCatalogServiceClient: SatelliteCatalogServiceClient + + @Autowired + private lateinit var aplSettings: AplSettings + + @Autowired + private lateinit var geoJsonLayerWriter: GeoJsonLayerWriter + + @Autowired + private lateinit var observationParametersDebugFileWriter: ObservationParametersDebugFileWriter + + /** + * Публичный путь 1, который сохраняет существующий контракт сервиса. + * Он строит все маршруты по доступным МПЛ-вариантам после фильтрации настроек и возвращает только контуры, + * без анализа суточной/витковой загрузки аппаратов. + */ + fun calculate(request: ComplexPlanCalculationRequestDTO): ComplexPlanCalculationOutput { + if (request.calculationMode == DynamicPlanCalculationMode.GREEDY) { + return calculateGreedyOutput(request) + } + + val startedAt = Instant.now() + val timings = CalculationTimings() + try { + val selectedSatelliteIds = prepareCalculationData(request) + val status = when { + catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND + selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES + else -> ComplexPlanCalculationStatus.COMPLETED + } + val planResult = if (status == ComplexPlanCalculationStatus.COMPLETED) { + buildPlan( + filterCoveredRoutes = request.filterCoveredRoutes, + revolutionMode = request.revolutionMode, + timings = timings + ) + } else { + PlanBuildResult(routes = emptyList()) + } + val durationMs = Duration.between(startedAt, Instant.now()).toMillis() + logCalculationTimings( + request = request, + status = status, + selectedSatelliteIds = selectedSatelliteIds, + routesCount = planResult.routes.size, + durationMs = durationMs, + timings = timings + ) + + return ComplexPlanCalculationOutput( + result = ComplexPlanCalculationResultDTO( + status = status, + requestId = request.requestId, + satelliteIds = selectedSatelliteIds, + missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) }, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + calculationMode = request.calculationMode, + revolutionMode = request.revolutionMode, + filterCoveredRoutes = request.filterCoveredRoutes, + routesCount = planResult.routes.size, + durationMs = durationMs + ), + routes = planResult.routes, + intervals = calculationIntervals() + ) + } catch (error: Exception) { + logCalculationFailureTimings(request, Duration.between(startedAt, Instant.now()).toMillis(), timings, error) + throw error + } + } + + /** + * Debug-сценарий для сравнения последовательного и chunked-parallel расчета МПЛ. + * Он выполняет только подготовку данных, построение интервалов и расчет параметров наблюдения, + * не строит маршруты и не сохраняет run в БД. + */ + fun calculateObservationParametersDebug( + request: ObservationParametersDebugRequestDTO + ): ObservationParametersDebugResultDTO { + val startedAt = Instant.now() + val timings = CalculationTimings() + val selectedSatelliteIds = prepareCalculationData( + ComplexPlanCalculationRequestDTO( + requestId = request.requestId, + satelliteIds = request.satelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd + ) + ) + val status = when { + catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND + selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES + else -> ComplexPlanCalculationStatus.COMPLETED + } + + if (status == ComplexPlanCalculationStatus.COMPLETED) { + getIntervalsForAplCatalog( + timings = timings, + parallelObservationParameters = request.parallelBallisticsCalculation, + observationParametersChunkSizeOverride = request.chunkSize, + observationParametersParallelismOverride = request.parallelism + ) + } + + val intervals = aplCatalog.flatMap { it.intevals } + val intervalsCount = intervals.size + val observationParametersCount = aplCatalog.sumOf { aplItem -> + aplItem.intevals.sumOf { interval -> interval.mpl.size } + } + val missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) } + val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) { + observationParametersDebugFileWriter.write( + ObservationParametersDebugFileDTO( + createdAt = Instant.now(), + requestId = request.requestId, + satelliteIds = selectedSatelliteIds, + missingSatelliteIds = missingSatelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + parallelBallisticsCalculation = request.parallelBallisticsCalculation, + chunkSize = request.chunkSize, + parallelism = request.parallelism, + intervalsCount = intervalsCount, + observationParametersCount = observationParametersCount, + intervals = intervals + ) + ).toString() + } else { + null + } + val chunksCount = if (request.parallelBallisticsCalculation) { + observationParametersChunksCount(request.chunkSize) + } else { + 0 + } + val totalMs = Duration.between(startedAt, Instant.now()).toMillis() + log.info( + "Debug-расчет параметров наблюдения завершен: requestId={}, satellites={}, status={}, parallelBallisticsCalculation={}, intervals={}, observationParameters={}, chunks={}, totalMs={}, intervalsMs={}, observationParametersMs={}", + request.requestId, + selectedSatelliteIds, + status, + request.parallelBallisticsCalculation, + intervalsCount, + observationParametersCount, + chunksCount, + totalMs, + timings.millis(CalculationTimingStage.INTERVAL_BUILD), + timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS) + ) + + return ObservationParametersDebugResultDTO( + requestId = request.requestId, + satelliteIds = selectedSatelliteIds, + missingSatelliteIds = missingSatelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + status = status, + parallelBallisticsCalculation = request.parallelBallisticsCalculation, + intervalsCount = intervalsCount, + observationParametersCount = observationParametersCount, + chunksCount = chunksCount, + intervalsMs = timings.millis(CalculationTimingStage.INTERVAL_BUILD), + observationParametersMs = timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS), + totalMs = totalMs, + savedToFile = resultFilePath != null, + resultFilePath = resultFilePath + ) + } + + /** + * Debug-сценарий greedy-математики. + * + * В отличие от обычного GREEDY-расчета, здесь не считается матрица планирования, не пишутся geo-layers + * и не создается запись run в БД. Endpoint нужен для быстрой диагностики greedy-выбора по заранее + * сохраненным интервалам с МПЛ; при включенном filterCoveredRoutes он строит маршруты только для + * проверки пересечения с еще не покрытой геометрией заявки. + */ + fun calculateGreedyMathDebug(request: GreedyMathDebugRequestDTO): GreedyMathDebugResultDTO { + val startedAt = Instant.now() + val timings = CalculationTimings() + val selectedSatelliteIds = prepareCalculationData( + request = ComplexPlanCalculationRequestDTO( + requestId = request.requestId, + satelliteIds = request.satelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + revolutionMode = request.revolutionMode + ), + writeLayers = false, + loadBallisticsData = false + ) + val status = when { + catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND + selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES + else -> ComplexPlanCalculationStatus.COMPLETED + } + + val observationParametersFile = observationParametersDebugFileWriter.filePathFor(request.requestId) + val intervals = if (status == ComplexPlanCalculationStatus.COMPLETED) { + val artifact = observationParametersDebugFileWriter.read(observationParametersFile) + filterIntervalsForGreedyMathFile( + intervals = artifact.intervals, + selectedSatelliteIds = selectedSatelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd + ) + } else { + emptyList() + } + val selection = if (status == ComplexPlanCalculationStatus.COMPLETED) { + timings.measure(CalculationTimingStage.GREEDY_SELECTION) { + selectGreedyRouteCandidates( + intervals = intervals, + settings = aplSettings.settings, + spacecraft = scg, + revolutionMode = request.revolutionMode + ) + } + } else { + GreedySelectionResult( + selectedCandidates = emptyList(), + failures = emptyList(), + workloads = emptyList() + ) + } + val routeBuildResult = if (status == ComplexPlanCalculationStatus.COMPLETED && request.filterCoveredRoutes) { + timings.measure(CalculationTimingStage.ROUTE_BUILD) { + buildRoutesForGreedyMath(selection.selectedCandidates) + } + } else { + GreedyMathRouteBuildResult(routes = emptyList(), failuresCount = 0) + } + val usefulAreaResult = if (status == ComplexPlanCalculationStatus.COMPLETED && request.filterCoveredRoutes) { + timings.measure(CalculationTimingStage.USEFUL_AREA_ANALYSIS) { + applyRemainingRequestGeometryFilterToGreedyMathRoutes(routeBuildResult.routes) + } + } else { + GreedyMathUsefulAreaResult( + routes = routeBuildResult.routes, + beforeCount = routeBuildResult.routes.size, + afterCount = routeBuildResult.routes.size + ) + } + val selectedCandidates = if (request.filterCoveredRoutes) { + usefulAreaResult.routes.map { it.candidate } + } else { + selection.selectedCandidates + } + + val intervalsCount = intervals.size + val observationParametersCount = intervals.sumOf { interval -> interval.mpl.size } + val totalMs = Duration.between(startedAt, Instant.now()).toMillis() + log.info( + "Debug-расчет greedy-математики из файла завершен: requestId={}, satellites={}, status={}, revolutionMode={}, observationParametersFile={}, intervals={}, observationParameters={}, selectedCandidates={}, failures={}, filterCoveredRoutes={}, routesBeforeUsefulArea={}, routesAfterUsefulArea={}, routeBuildFailures={}, totalMs={}, greedySelectionMs={}, routeBuildMs={}, usefulAreaAnalysisMs={}", + request.requestId, + selectedSatelliteIds, + status, + request.revolutionMode, + observationParametersFile, + intervalsCount, + observationParametersCount, + selectedCandidates.size, + selection.failures.size, + request.filterCoveredRoutes, + usefulAreaResult.beforeCount, + usefulAreaResult.afterCount, + routeBuildResult.failuresCount, + totalMs, + timings.millis(CalculationTimingStage.GREEDY_SELECTION), + timings.millis(CalculationTimingStage.ROUTE_BUILD), + timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS) + ) + + return GreedyMathDebugResultDTO( + requestId = request.requestId, + satelliteIds = selectedSatelliteIds, + missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) }, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + status = status, + revolutionMode = request.revolutionMode, + observationParametersFilePath = if (status == ComplexPlanCalculationStatus.COMPLETED) observationParametersFile.toString() else null, + intervalsCount = intervalsCount, + observationParametersCount = observationParametersCount, + selectedCandidatesCount = selectedCandidates.size, + failuresCount = selection.failures.size, + selectedCandidates = selectedCandidates.map { it.toDebugDto() }, + filterCoveredRoutes = request.filterCoveredRoutes, + routesBeforeUsefulAreaFilterCount = usefulAreaResult.beforeCount, + routesAfterUsefulAreaFilterCount = usefulAreaResult.afterCount, + routeBuildFailuresCount = routeBuildResult.failuresCount, + greedySelectionMs = timings.millis(CalculationTimingStage.GREEDY_SELECTION), + routeBuildMs = timings.millis(CalculationTimingStage.ROUTE_BUILD), + usefulAreaAnalysisMs = timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS), + totalMs = totalMs + ) + } + + private fun buildRoutesForGreedyMath( + selectedCandidates: List + ): GreedyMathRouteBuildResult { + var failuresCount = 0 + val routes = selectedCandidates.mapNotNull { candidate -> + val contour = calculateSurveyContour(candidate.toContourRequest()) + if (contour == null) { + failuresCount += 1 + null + } else { + GreedyMathBuiltRoute( + candidate = candidate, + route = SurveyContour( + mpl = candidate.mpl, + time = candidate.timeBegin, + duration = candidate.durationSeconds.toDouble(), + rev = candidate.revolution, + gamma = candidate.gamma, + geometry = contour + ) + ) + } + } + + return GreedyMathRouteBuildResult(routes = routes, failuresCount = failuresCount) + } + + private fun applyRemainingRequestGeometryFilterToGreedyMathRoutes( + routes: List + ): GreedyMathUsefulAreaResult { + val requestGeometry = requestCoverageGeometry() + if (requestGeometry == null || requestGeometry.isEmpty) { + log.warn("Фильтрация полезной площади в greedy-math пропущена: нет геометрии заявки") + return GreedyMathUsefulAreaResult(routes = routes, beforeCount = routes.size, afterCount = routes.size) + } + + val remainingRoutes = routes.toMutableList() + val filteredRoutes = filterRoutesByRemainingRequestGeometry( + routes = routes.map { it.route }, + requestGeometry = requestGeometry + ).mapNotNull { surveyRoute -> + val index = remainingRoutes.indexOfFirst { it.route == surveyRoute } + if (index < 0) { + null + } else { + remainingRoutes.removeAt(index) + } + } + + return GreedyMathUsefulAreaResult( + routes = filteredRoutes, + beforeCount = routes.size, + afterCount = filteredRoutes.size + ) + } + + /** + * Debug-сценарий замера построения маршрутов по всем параметрам видимости из файлового артефакта. + * + * В отличие от greedy-math, здесь нет greedy-выбора и ресурсных ограничений: каждый MPL, оставшийся после + * фильтрации по КА и временному интервалу, независимо отправляется в построение контура маршрута. + */ + fun calculateTestCalcMarsDebug(request: TestCalcMarsDebugRequestDTO): TestCalcMarsDebugResultDTO { + val startedAt = Instant.now() + val timings = CalculationTimings() + val selectedSatelliteIds = prepareCalculationData( + request = ComplexPlanCalculationRequestDTO( + requestId = request.requestId, + satelliteIds = request.satelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd + ), + writeLayers = false, + loadBallisticsData = false + ) + val status = when { + catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND + selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES + else -> ComplexPlanCalculationStatus.COMPLETED + } + + val observationParametersFile = observationParametersDebugFileWriter.filePathFor(request.requestId) + val intervals = if (status == ComplexPlanCalculationStatus.COMPLETED) { + val artifact = observationParametersDebugFileWriter.read(observationParametersFile) + filterIntervalsForGreedyMathFile( + intervals = artifact.intervals, + selectedSatelliteIds = selectedSatelliteIds, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd + ) + } else { + emptyList() + } + val routeBuildResult = if (status == ComplexPlanCalculationStatus.COMPLETED) { + timings.measure(CalculationTimingStage.ROUTE_BUILD) { + buildRoutesForAllObservationParameters(intervals) + } + } else { + TestCalcMarsRouteBuildResult(attemptsCount = 0, routesCount = 0, failuresCount = 0) + } + + val intervalsCount = intervals.size + val observationParametersCount = intervals.sumOf { interval -> interval.mpl.size } + val totalMs = Duration.between(startedAt, Instant.now()).toMillis() + log.info( + "Debug-построение маршрутов по всем параметрам видимости завершено: requestId={}, satellites={}, status={}, observationParametersFile={}, intervals={}, observationParameters={}, routeBuildAttempts={}, routes={}, failures={}, routeBuildMs={}, totalMs={}", + request.requestId, + selectedSatelliteIds, + status, + observationParametersFile, + intervalsCount, + observationParametersCount, + routeBuildResult.attemptsCount, + routeBuildResult.routesCount, + routeBuildResult.failuresCount, + timings.millis(CalculationTimingStage.ROUTE_BUILD), + totalMs + ) + + return TestCalcMarsDebugResultDTO( + requestId = request.requestId, + satelliteIds = selectedSatelliteIds, + missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) }, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + status = status, + observationParametersFilePath = if (status == ComplexPlanCalculationStatus.COMPLETED) observationParametersFile.toString() else null, + intervalsCount = intervalsCount, + observationParametersCount = observationParametersCount, + routeBuildAttemptsCount = routeBuildResult.attemptsCount, + routesCount = routeBuildResult.routesCount, + routeBuildFailuresCount = routeBuildResult.failuresCount, + routeBuildMs = timings.millis(CalculationTimingStage.ROUTE_BUILD), + totalMs = totalMs + ) + } + + private fun buildRoutesForAllObservationParameters( + intervals: List + ): TestCalcMarsRouteBuildResult { + var attemptsCount = 0 + var routesCount = 0 + var failuresCount = 0 + intervals.forEach { interval -> + interval.mpl.forEach { mpl -> + attemptsCount += 1 + val contour = calculateSurveyContour(mpl.toContourRequest()) + if (contour == null) { + failuresCount += 1 + } else { + routesCount += 1 + } + } + } + + return TestCalcMarsRouteBuildResult( + attemptsCount = attemptsCount, + routesCount = routesCount, + failuresCount = failuresCount + ) + } + + private fun calculateGreedyOutput(request: ComplexPlanCalculationRequestDTO): ComplexPlanCalculationOutput { + val startedAt = Instant.now() + val timings = CalculationTimings() + try { + val selectedSatelliteIds = prepareCalculationData(request) + val status = when { + catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND + selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES + else -> ComplexPlanCalculationStatus.COMPLETED + } + val planResult = if (status == ComplexPlanCalculationStatus.COMPLETED) { + calcGreedyMars( + filterCoveredRoutes = request.filterCoveredRoutes, + revolutionMode = request.revolutionMode, + timings = timings + ) + } else { + GreedyPlanResult(routes = emptyList(), failures = emptyList(), workloads = emptyList()) + } + val coverageSummary = if (status == ComplexPlanCalculationStatus.COMPLETED) { + requestCoverageGeometry()?.let { summarizeRouteCoverage(planResult.routes, it) } + } else { + null + } + val durationMs = Duration.between(startedAt, Instant.now()).toMillis() + logCalculationTimings( + request = request, + status = status, + selectedSatelliteIds = selectedSatelliteIds, + routesCount = planResult.routes.size, + durationMs = durationMs, + timings = timings + ) + + return ComplexPlanCalculationOutput( + result = ComplexPlanCalculationResultDTO( + status = status, + requestId = request.requestId, + satelliteIds = selectedSatelliteIds, + missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) }, + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + calculationMode = request.calculationMode, + revolutionMode = request.revolutionMode, + filterCoveredRoutes = request.filterCoveredRoutes, + routesCount = planResult.routes.size, + lastRouteStart = coverageSummary?.lastRouteStart, + lastRouteEnd = coverageSummary?.lastRouteEnd, + coveredAreaPercent = coverageSummary?.coveredAreaPercent, + durationMs = durationMs + ), + routes = planResult.routes, + intervals = calculationIntervals() + ) + } catch (error: Exception) { + logCalculationFailureTimings(request, Duration.between(startedAt, Instant.now()).toMillis(), timings, error) + throw error + } + } + + private fun logCalculationTimings( + request: ComplexPlanCalculationRequestDTO, + status: ComplexPlanCalculationStatus, + selectedSatelliteIds: List, + routesCount: Int, + durationMs: Long, + timings: CalculationTimings + ) { + log.info( + "Расчет комплексного плана завершен: requestId={}, mode={}, revolutionMode={}, status={}, satellites={}, routes={}, totalMs={}, intervalsMs={}, observationParametersMs={}, greedySelectionMs={}, usefulAreaAnalysisMs={}", + request.requestId, + request.calculationMode, + request.revolutionMode, + status, + selectedSatelliteIds, + routesCount, + durationMs, + timings.millis(CalculationTimingStage.INTERVAL_BUILD), + timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS), + timings.millis(CalculationTimingStage.GREEDY_SELECTION), + timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS) + ) + } + + private fun logCalculationFailureTimings( + request: ComplexPlanCalculationRequestDTO, + durationMs: Long, + timings: CalculationTimings, + error: Exception + ) { + log.warn( + "Расчет комплексного плана прерван: requestId={}, mode={}, revolutionMode={}, totalMs={}, intervalsMs={}, observationParametersMs={}, greedySelectionMs={}, usefulAreaAnalysisMs={}, errorType={}, errorMessage={}", + request.requestId, + request.calculationMode, + request.revolutionMode, + durationMs, + timings.millis(CalculationTimingStage.INTERVAL_BUILD), + timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS), + timings.millis(CalculationTimingStage.GREEDY_SELECTION), + timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS), + error.javaClass.simpleName, + error.message + ) + } + + /** + * Вход во второй путь расчета. Сейчас он отделен от старого REST-контракта и возвращает расширенный результат + * greedy-планирования: построенные маршруты, диагностические отказы и загрузку аппаратов. + */ + internal fun calculateGreedy(request: ComplexPlanCalculationRequestDTO): GreedyPlanResult { + val selectedSatelliteIds = prepareCalculationData(request) + if (catalog.isEmpty() || selectedSatelliteIds.isEmpty()) { + return GreedyPlanResult( + routes = emptyList(), + failures = emptyList(), + workloads = emptyList() + ) + } + return calcGreedyMars( + filterCoveredRoutes = request.filterCoveredRoutes, + revolutionMode = request.revolutionMode + ) + } + + private fun clearAllData() { + catalog.clear() + scg.clear() + aplCatalog.clear() + activeCalculationStart = null + activeCalculationEnd = null + } + + /** + * Формирует рабочую группировку КА для расчета. + * Здесь не создаются "пустые" аппараты для отсутствующих NORAD: такие КА исключаются до загрузки + * баллистики, иначе дальнейший расчет получил бы трассы и МПЛ для невалидного состава группировки. + */ + private fun fetchSatellites(requestedSatelliteIds: Collection): List { + val requestedSatelliteIds = requestedSatelliteIds.distinct() + if (requestedSatelliteIds.isEmpty()) { + log.warn("Расчет комплексного плана прекращен: в запросе не задан список КА") + return emptyList() + } + + val catalogSatelliteIds = satelliteCatalogServiceClient.getSatellites() + .map { it.id } + .toSet() + val selectedSatelliteIds = selectSatellitesForCalculation(requestedSatelliteIds, catalogSatelliteIds) + + selectedSatelliteIds.missingIds.forEach { satelliteId -> + log.warn("КА {} отсутствует в satellite-catalog-service и исключен из расчета", satelliteId) + } + if (selectedSatelliteIds.existingIds.isEmpty()) { + log.warn("Расчет комплексного плана прекращен: ни один КА из запроса {} не найден в satellite-catalog-service", requestedSatelliteIds) + return emptyList() + } + + selectedSatelliteIds.existingIds.forEach { satelliteId -> + scg.add(SpaceCraft(satelliteId)) + } + return selectedSatelliteIds.existingIds + } + + private fun loadOrbitAvailability() { + scg.forEach { ka -> + val a = ballTask.getOrbitAvailability(ka.id) + ka.timeStart = a.timeStart + ka.timeStop = a.timeStop + } + } + + private fun loadFlightLines( + calculationStart: LocalDateTime, + calculationEnd: LocalDateTime + ) { + scg.forEach { ka -> + ka.flightLine.addAll(ballTask.getFlightLine(ka.id, calculationStart, calculationEnd)) + } + } + + /** + * Единая подготовка состояния калькулятора перед любым вариантом расчета. + * + * Метод очищает mutable-состояние предыдущего запуска, загружает заявку, сверяет группировку с каталогом, + * подтягивает доступность орбит и трассы полета. После него `catalog`, `scg` и `aplCatalog` согласованы + * между собой и могут использоваться как вход для старого или greedy-пути. + */ + private fun prepareCalculationData( + request: ComplexPlanCalculationRequestDTO, + writeLayers: Boolean = true, + loadBallisticsData: Boolean = true + ): List { + clearAllData() + activeCalculationStart = request.calculationStart + activeCalculationEnd = request.calculationEnd + loadRequest(request.requestId) + if (catalog.isEmpty()) { + log.warn("Расчет комплексного плана прекращен: заявка {} не найдена", request.requestId) + return emptyList() + } + val selectedSatelliteIds = fetchSatellites(request.satelliteIds) + if (selectedSatelliteIds.isEmpty()) { + return emptyList() + } + + if (loadBallisticsData) { + loadOrbitAvailability() + loadFlightLines(request.calculationStart, request.calculationEnd) + scg.forEach { s -> + log.info( + "Загружена трасса КА: satelliteId={}, orbitStart={}, orbitEnd={}, points={}", + s.id, + s.timeStart, + s.timeStop, + s.flightLine.size + ) + } + } + if (writeLayers) { + geoJsonLayerWriter.write(GeoJsonItem.createRequestLay(catalog), "Requests") + } + log.info("Загружена заявка для расчета комплексного плана: {}", request.requestId) + aplCatalog.addAll(convertCatalogToAplCatalog(catalog)) + + if (writeLayers) { + geoJsonLayerWriter.write(GeoJsonItem.createAplCatalogLay(aplCatalog), "aplCatalog") + } + log.info("Подготовлено объектов APL-каталога: {}", aplCatalog.size) + return selectedSatelliteIds + } + + private fun loadRequest(requestId: UUID) { + requestServiceClient.getRequest(requestId)?.let { catalog.add(it) } + } + + private fun convertCatalogToAplCatalog(value : List) : List { + return value.map { AplObjItem(it) } + } + + private fun calculationIntervals(): List = + aplCatalog.flatMap { aplItem -> aplItem.intevals } + + /** + * Старый путь построения плана после подготовки данных. + * + * Метод намеренно оставляет прежнее поведение: интервалы и МПЛ считаются для всей заявки, + * а затем строятся маршруты по всем подходящим МПЛ-вариантам. Здесь нет учета занятости КА, + * поэтому один аппарат может получить пересекающиеся маршруты; этот путь используется как полный + * отладочный слой маршрутов и как сохраненный контракт старого расчета. + */ + private fun buildPlan( + log : Boolean = true, + filterCoveredRoutes: Boolean = false, + revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + timings: CalculationTimings? = null + ): PlanBuildResult { + writeLog = log + if (scg.isEmpty()) { + this.log.warn("Расчет комплексного плана прекращен: нет КА для расчета") + return PlanBuildResult(routes = emptyList()) + } + + //Посчитать интервалы для района по группировке + getIntervalsForAplCatalog(timings, revolutionMode = revolutionMode) + + //Пока что строим все маршруты, для отладки + val survList = calcAllMars() + val routes = applyCoveredRouteFilter(survList, filterCoveredRoutes, timings) + val surveyLayer = GeoJsonItem.createSurveyLay(routes) + + geoJsonLayerWriter.write(surveyLayer, "Surveys") + return PlanBuildResult(routes = routes) + } + + /** + * Второй путь построения маршрутов. + * + * Сначала переиспользует общий расчет интервалов и матрицы планирования, затем запускает greedy-выбор: + * каждый интервал закрывается не более чем одним включением, выбранные включения сразу занимают ресурс КА, + * а поздние интервалы уже видят эту загрузку. Контур маршрута строится в момент проверки кандидата, потому + * что отсутствие точек орбиты или невозможность рассчитать контур тоже делает кандидат непригодным. + */ + private fun calcGreedyMars( + log: Boolean = true, + filterCoveredRoutes: Boolean = false, + revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + timings: CalculationTimings? = null + ): GreedyPlanResult { + writeLog = log + if (scg.isEmpty()) { + this.log.warn("Расчет комплексного плана прекращен: нет КА для расчета") + return GreedyPlanResult(routes = emptyList(), failures = emptyList(), workloads = emptyList()) + } + + getIntervalsForAplCatalog(timings, revolutionMode = revolutionMode) + + val contours = linkedMapOf() + val selection = timings.measure(CalculationTimingStage.GREEDY_SELECTION) { + selectGreedyRouteCandidates( + intervals = aplCatalog.flatMap { it.intevals }, + settings = aplSettings.settings, + spacecraft = scg, + revolutionMode = revolutionMode + ) { candidate -> + val surveyContourTask = candidate.toContourRequest() + val contour = calculateSurveyContour(surveyContourTask) ?: return@selectGreedyRouteCandidates false + contours[candidate] = contour + true + } + } + val routes = selection.selectedCandidates.map { candidate -> + SurveyContour( + mpl = candidate.mpl, + rev = candidate.revolution, + gamma = candidate.gamma, + time = candidate.timeBegin, + duration = candidate.durationSeconds.toDouble(), + geometry = contours.getValue(candidate) + ) + } + val filteredRoutes = applyCoveredRouteFilter(routes, filterCoveredRoutes, timings) + + geoJsonLayerWriter.write(GeoJsonItem.createSurveyLay(filteredRoutes), "GreedySurveys") + return GreedyPlanResult( + routes = filteredRoutes, + failures = selection.failures, + workloads = selection.workloads + ) + } + + private fun applyCoveredRouteFilter( + routes: List, + filterCoveredRoutes: Boolean, + timings: CalculationTimings? = null + ): List { + if (!filterCoveredRoutes) { + return routes + } + + val requestGeometry = requestCoverageGeometry() + if (requestGeometry == null) { + log.warn("Фильтрация уже покрытых маршрутов пропущена: нет геометрии заявки") + return routes + } + + val filteredRoutes = timings.measure(CalculationTimingStage.USEFUL_AREA_ANALYSIS) { + filterRoutesByRemainingRequestGeometry(routes, requestGeometry) + } + log.info( + "Фильтрация уже покрытых маршрутов: было={}, осталось={}", + routes.size, + filteredRoutes.size + ) + return filteredRoutes + } + + private fun requestCoverageGeometry(): Geometry? = + aplCatalog + .map { it.geometry } + .takeIf { it.isNotEmpty() } + ?.reduce { coverage, geometry -> coverage.addCoverage(geometry) } + + /** + * Рассчитывает маршруты по всем прохождениям интервалов для пути 1. + * + * Для каждого интервала перебираются все МПЛ-варианты, прошедшие фильтры настроек. + * Каждый успешный вариант превращается в отдельный SurveyContour; пересечения маршрутов и лимиты работы КА + * здесь специально не проверяются, чтобы старый путь оставался "полным" представлением доступных наблюдений. + **/ + private fun calcAllMars() : List { + val result = mutableListOf() + aplCatalog.forEach { aplItem -> + aplItem.intevals.forEach { intervalItem -> + if (intervalItem.mpl.isEmpty()) { + log.warn("Маршрут не строится: для интервала {} нет наблюдений", intervalItem.id) + return@forEach + } + selectMplItemsForRouteBuild(intervalItem.mpl, aplSettings.settings).forEach { mplItem -> + val surveyContourTask = ContourRequestDTO( + satelliteId = mplItem.noradId, + time = mplItem.timeBegin, + gamma = (mplItem.gammaMax + mplItem.gammaMin) / 2, + duration = max(Duration.between(mplItem.timeBegin, mplItem.timeEnd).seconds.toDouble(), 1.0), + routeWidthKm = aplSettings.settings.marWidth, + step = aplSettings.settings.contourStepBuild, + ) + val contour = calculateSurveyContour(surveyContourTask) ?: return@forEach + result.add(SurveyContour( + mpl = mplItem, + rev = mplItem.revolutionBegin, + gamma = surveyContourTask.gamma, + time = surveyContourTask.time, + duration = surveyContourTask.duration, + geometry = contour, + )) + } + } + } + return result + } + + /** + * Строит геометрию маршрута по выбранному включению. + * + * Метод запрашивает у ballistics-service точки орбиты на интервале включения, рассчитывает угол захвата + * под заданную ширину маршрута и передает данные в SurveyContourBuilder. Null означает, что маршрут + * нельзя использовать в плане: не хватило орбитальных точек или не удалось вычислить геометрию съемки. + */ + private fun calculateSurveyContour(request: ContourRequestDTO): String? { + val durationMs = (request.duration * 1000.0).roundToLong().coerceAtLeast(1L) + val stepMs = (request.step * 1000.0).roundToLong().coerceAtLeast(1L) + val orbitalPoints = ballTask.getExactTime( + request.satelliteId, + ExactTimePositionRequestDTO( + time = request.time, + timeStop = request.time.plusNanos(durationMs * 1_000_000), + stepMs = stepMs + ) + ) + if (orbitalPoints.isEmpty()) { + log.warn( + "Контур маршрута не построен: баллистика не вернула точки орбиты, satelliteId={}, time={}, duration={}", + request.satelliteId, + request.time, + request.duration + ) + return null + } + + val captureAngle = captureAngleCalculator.calculateCaptureAngle(request.routeWidthKm, orbitalPoints) + if (captureAngle == null) { + log.warn( + "Контур маршрута не построен: не удалось рассчитать угол захвата, satelliteId={}, routeWidthKm={}", + request.satelliteId, + request.routeWidthKm + ) + return null + } + + return surveyContourBuilder.build( + points = orbitalPoints, + roll = request.gamma, + capture = captureAngle + ) + } + + /** + * Адаптер greedy-кандидата к контракту построения контура. + * Ширина маршрута и шаг дискретизации берутся из общих APL-настроек, чтобы оба пути строили геометрию + * с одинаковыми параметрами. + */ + private fun GreedyRouteCandidate.toContourRequest() = + ContourRequestDTO( + satelliteId = satelliteId, + time = timeBegin, + gamma = gamma, + duration = durationSeconds.toDouble(), + routeWidthKm = aplSettings.settings.marWidth, + step = aplSettings.settings.contourStepBuild, + ) + + private fun SquareViewParamDTO.toContourRequest() = + ContourRequestDTO( + satelliteId = noradId, + time = timeBegin, + gamma = (gammaMax + gammaMin) / 2, + duration = max(Duration.between(timeBegin, timeEnd).seconds.toDouble(), 1.0), + routeWidthKm = aplSettings.settings.marWidth, + step = aplSettings.settings.contourStepBuild, + ) + +// GEOMETRYCOLLECTION(POLYGON ((37.397461 56.28301, 36.260376 55.862982, 36.5625 55.279115, 38.056641 55.059495, 39.100342 55.702355, 38.551025 56.12106, 37.397461 56.28301)),POLYGON ((36.86286657854737 56.12843639411393, 36.83227584529376 56.06693470293402, 36.801769154413996 56.00542660700639, 36.771346053487264 55.94391213659794, 36.74100609322191 55.88239132176715, 36.7107488274289 55.82086419236608, 36.68057381299534 55.75933077804233, 36.65047748499836 55.697791028093725, 36.620465549539595 55.63624509308851, 36.590534553670196 55.57469295704776, 36.5606840662853 55.51313464863579, 36.530913659227544 55.45157019631645, 36.5012229072623 55.38999962835492, 36.05873799410527 55.44841679624824, 36.08776466001127 55.51007901448405, 36.116868663812426 55.57173546329989, 36.14605042058473 55.63338611616689, 36.17531034827233 55.69503094636904, 36.20464886771195 55.7566699270015, 36.23406927671903 55.81830286567251, 36.2635661497812 55.87993003349437, 36.29314289525238 55.94155127319149, 36.32279994575266 56.00316655692413, 36.35253773692209 56.06477585666016, 36.38235670744684 56.12637914417322, 36.41225729908539 56.18797639104081, 36.86286657854737 56.12843639411393)),POLYGON ((37.374094185539015 56.31342214091461, 37.34257322335814 56.25203255665248, 37.311140177791955 56.19063611901584, 37.27979457498279 56.12923286076635, 37.24853594435422 56.06782281443821, 37.2173638185832 56.006406012340285, 37.18627773357249 55.94498248655842, 37.15527722842326 55.8835522689574, 37.124361845408075 55.82211539118318, 37.09353112994406 55.760671884664916, 37.06278146917312 55.699221550268874, 37.03211863032975 55.637764844266734, 37.00153911332062 55.57630159881958, 36.971042475842964 55.514831844328995, 36.94062827860779 55.45335561098408, 36.91029608531463 55.39187292876364, 36.8800454626266 55.3303838274379, 36.84987598014567 55.268888336570605, 36.8197872103881 55.20738648552083, 36.379148592017664 55.267077245057735, 36.408583235948925 55.3286731444561, 36.43809633502882 55.39026303419816, 36.46768830703589 55.45184688666682, 36.49735957261246 55.51342467405175, 36.52711055528883 55.57499636834763, 36.5569416815079 55.63656194135231, 36.58685338064979 55.69812136466492, 36.61684608505684 55.75967460968404, 36.64692313704502 55.821221636182564, 36.677079056189065 55.88276240905972, 36.70731729614335 55.944296919800024, 36.737638301319635 56.00582513882355, 36.76804251922152 56.067347036345836, 36.79853040047113 56.1288625823759, 36.82910239883583 56.190371746714234, 36.859758971255395 56.25187449895081, 36.8905005778693 56.313370808462935, 36.921327682044286 56.37486064441323, 37.374094185539015 56.31342214091461)),POLYGON ((37.787105567891494 56.2943724136141, 37.754932366663105 56.23307750856126, 37.72284943290911 56.171775390991364, 37.69085628204084 56.110466095512024, 37.658952432810715 56.04914965649078, 37.62713740728392 55.98782610805759, 37.595410730810535 55.92649548410685, 37.56377193199783 55.86515781829983, 37.53222054268282 55.80381314406674, 37.500756097905246 55.742461494608996, 37.46937813588054 55.68110290290138, 37.43808285804391 55.61973697526561, 37.40687638023442 55.558364560473585, 37.37575501805301 55.49698529721743, 37.34471832212136 55.435599217402284, 37.31376584610594 55.374206352711425, 37.28289714669266 55.31280673460816, 37.252111783561865 55.251400394338035, 37.2214093193634 55.18998736293067, 37.19078931969217 55.12856767120184, 36.74856083531451 55.19001519767402, 36.77852941972015 55.25153242497892, 36.8085782423774 55.31304335144423, 36.83870772830057 55.374547948028976, 36.868918305401714 55.43604618548985, 36.89921040451503 55.497538034379346, 36.92958445942145 55.55902346504384, 36.96004090687345 55.620502447621675, 36.99058018662027 55.68197495204115, 37.02120582013822 55.74344113126988, 37.05191198966952 55.80490055835292, 37.08270232946442 55.866353419047556, 37.11357729148183 55.92779968226204, 37.14453733080749 55.98923931669039, 37.17558290568056 56.05067229081021, 37.20671447752059 56.1120985728807, 37.23793251095473 56.173518130940444, 37.269237473845166 56.234930932805426, 37.30062983731688 56.296336946066745, 37.33211007578567 56.35773613808844, 37.787105567891494 56.2943724136141)),POLYGON ((38.18357851116821 56.238305292902886, 38.15080596978912 56.1771044662348, 38.11812574313705 56.11589607789891, 38.08553733810567 56.05468016425039, 38.053040264969816 55.993456761393055, 38.02063403735725 55.93222590518176, 37.98831817222048 55.87098763122467, 37.95609218980901 55.809741974885654, 37.923955613641695 55.74848897128653, 37.891907970479565 55.68722865530936, 37.859948790298716 55.62596106159879, 37.82807412884916 55.56468561621346, 37.79629036770221 55.50340353379011, 37.76459367795362 55.44211427234814, 37.732983602144024 55.38081786541905, 37.70145968589752 55.319514346301915, 37.67002147789626 55.25820374806554, 37.63866852985523 55.19688610355058, 37.607400396497326 55.135561445371565, 37.5762166355285 55.074229805919025, 37.12980564236885 55.137827942962545, 37.160336512693966 55.1992613094187, 37.19094954599485 55.260688065626496, 37.2216451754285 55.322108181015984, 37.25242383709086 55.383521624804956, 37.283285970041376 55.44492836599694, 37.31423201632777 55.5063283733792, 37.34526242101109 55.567721615520746, 37.37637763219099 55.62910806077039, 37.40758131032927 55.69048804126235, 37.43886738378673 55.75186076731732, 37.47023962699517 55.81322660346214, 37.5016985004354 55.87458551698282, 37.53324446775766 55.9359374749404, 37.56487799580866 55.99728244416882, 37.59659955465852 56.058620391272754, 37.62840961762824 56.11995128262538, 37.66030866131739 56.1812750843662, 37.69229716563195 56.24259176239874, 37.724375613812555 56.30390128238838, 38.18357851116821 56.238305292902886)),POLYGON ((38.582585827456654 56.18208716204893, 38.54921371955402 56.12098401805472, 38.51593595610682 56.05987295332675, 38.482752035723024 55.99875400600691, 38.44966146042929 55.93762721397423, 38.4166637356425 55.87649261484728, 38.38375837014157 55.81535024598666, 38.35094487603953 55.754200144497354, 38.318222768755845 55.693042347231156, 38.28559156698904 55.63187689078899, 38.25305079268958 55.57070381152328, 38.22059635452304 55.50952235570751, 38.18823490324764 55.44833410310417, 38.155962464025315 55.38713833143868, 38.123778571519395 55.32593507590641, 38.09168276351322 55.26472437145969, 38.05967458088429 55.20350625281004, 38.02775356757917 55.142280754430324, 37.995919270588224 55.08104791055691, 37.96417123992089 55.01980775519185, 37.51122893311556 55.0859407720278, 37.54231936322492 55.14728746650345, 37.57349385706382 55.20862723408846, 37.604752855704376 55.26996004265604, 37.63609680319629 55.33128585985703, 37.66752614659179 55.392604653117886, 37.6990413359705 55.453916389638565, 37.73064282446458 55.51522103639046, 37.76233106828427 55.576518560114295, 37.794109867353576 55.63780947065399, 37.82597289450051 55.69909261852606, 37.857924064777656 55.76036854547385, 37.88996384693743 55.82163721713235, 37.92209271294185 55.88289859890087, 37.9543111379895 55.94415265594074, 37.9866196005429 56.00539935317305, 38.019018582355926 56.0666386552764, 38.0515085685017 56.127870526684646, 38.08409004740064 56.18909493158441, 38.116763510848806 56.2503118339129, 38.582585827456654 56.18208716204893)),POLYGON ((38.97070889678638 56.124061264496646, 38.936759513360876 56.06305604447322, 38.90290640080406 56.00204254761551, 38.8691490500906 55.94102081382274, 38.83548695564598 55.87999088272002, 38.801919615318184 55.81895279366094, 38.76844653034923 55.75790658573004, 38.735067205347356 55.696852297745345, 38.7017811482592 55.635789968260774, 38.66858787034229 55.574719635568655, 38.635486886138 55.513641337702076, 38.602473954394696 55.45255414499122, 38.56955600258029 55.39145999444591, 38.53672890705141 55.33035798744899, 38.5039921951914 55.26924816083357, 38.47134539753496 55.20813055117938, 38.43878804774269 55.147005194815065, 38.40631968257565 55.0858721278204, 38.373939841870104 55.02473138602862, 38.34164806851274 54.96358300502852, 37.88013249183751 55.03256667072851, 37.91175916265749 55.09382689046877, 37.94347169622526 55.155079870755806, 37.975270540920874 55.216325577937354, 38.00715614813623 55.277563978129045, 38.03912897229984 55.33879503721227, 38.071189470901885 55.40001872083204, 38.10333810451957 55.46123499439476, 38.135575336842656 55.52244382306615, 38.167905109271416 55.583645889513114, 38.20032083319652 55.64483969421595, 38.23282656626062 55.70602595135891, 38.26542278483659 55.76720462496017, 38.29810996854188 55.828375678791794, 38.33088860026559 55.88953907637734, 38.36375916619584 55.95069478098961, 38.39672215584743 56.01184275564819, 38.429778062089746 56.07298296311718, 38.462927381174985 56.13411536590266, 38.49617061276655 56.195239926250295, 38.97070889678638 56.124061264496646)),POLYGON ((39.267241022679436 55.93659989554567, 39.23295492350333 55.875672345247565, 39.19876579464455 55.81473626082574, 39.16467312754646 55.75379168326779, 39.13067641706992 55.69283865328208, 39.09677516146532 55.631877211300434, 39.06296886234483 55.57090739748049, 39.02925702465498 55.50992925170841, 38.99563915664947 55.44894281360122, 38.96211085484352 55.387946980969744, 38.92867935137203 55.32694404037118, 38.895340361454764 55.26593292080997, 38.86209340628324 55.20491366066265, 38.38705715890368 55.27756464905193, 38.4196183637626 55.33870345518393, 38.45226934722532 55.39983454668984, 38.48501057934671 55.46095788725798, 38.51784615452785 55.5220743277203, 38.55076919630254 55.583182027620005, 38.58378391641012 55.644281869462944, 38.616890797695774 55.70537381575066, 38.650090326275546 55.76645782872894, 38.68338299156361 55.827533870385516, 38.7167692862995 55.888601902447554, 38.750249706575865 55.949661886379296, 38.783824751866504 56.01071378337954, 39.267241022679436 55.93659989554567))) + + /** + * Строит геометрические интервалы съемки для всех объектов APL-каталога на одном направлении витка. + * + * Интервалы строятся по трассе первого КА из группировки: это существующее допущение старого алгоритма, + * которое определяет разбиение объекта на участки. Дальше матрица планирования уже считается по всей + * выбранной группировке КА, поэтому здесь формируется только геометрическая основа для последующего МПЛ. + */ + private fun calcIntervalsForApl( + flightLine: Pair, + revSign : RevolutionSign, + writeLayers: Boolean = true + ) { + if (writeLog) { log.info("Построение интервалов начало") } + val intBuild = _root_ide_package_.space.nstart.pcp.complan.apl.IntervalsBuilder(false) + val coverageChecker = if (intervalCoverageCheckEnabled) IntervalCoverageChecker() else null + val coverageGapItems = mutableListOf() + + for (i in 0..() + when (revSign) { + RevolutionSign.ASC -> { + interval.addAll(intBuild.getIntervals(contour = aplCatalog[i].geometry as Polygon, + flightLine = flightLine.second, + offsetKm = aplSettings.settings.marWidth * INTERVAL_ROUTE_WIDTH_RATIO, + overlapKm = aplSettings.settings.marOverlap)) + } + RevolutionSign.DESC -> { + interval.addAll(intBuild.getIntervals(contour = aplCatalog[i].geometry as Polygon, + flightLine = flightLine.first, + offsetKm = aplSettings.settings.marWidth * INTERVAL_ROUTE_WIDTH_RATIO, + overlapKm = aplSettings.settings.marOverlap)) + } + } + + if (coverageChecker != null) { + val uncoveredGeometry = coverageChecker.findUncoveredGeometry(aplCatalog[i].geometry, interval.map { it as Geometry }) + if (uncoveredGeometry.isEmpty || uncoveredGeometry.area <= INTERVAL_COVERAGE_AREA_TOLERANCE) { + if (writeLog) { log.info("Интервалы полностью покрывают участок {} для {}", aplCatalog[i].id, revSign) } + } else { + log.warn( + "Интервалы не полностью покрывают участок {} для {}. Остаточная площадь: {}", + aplCatalog[i].id, + revSign, + uncoveredGeometry.area + ) + coverageGapItems.addAll(createCoverageGapItems(aplCatalog[i], revSign, uncoveredGeometry)) + } + } + + aplCatalog[i].intevals.addAll( + interval.map { + IntervalItemDTO( + regionId = aplCatalog[i].id, + geometry = it.toString(), + revSign = revSign + ) + } + ) + + if (writeLog) { log.info("Обработан участок №$i имя участка ${aplCatalog[i].name}") } + } + if (intervalCoverageCheckEnabled && writeLayers) { + geoJsonLayerWriter.write(coverageGapItems, "UncoveredIntervals${revSign.name}") + } + if (writeLog) { log.info("Построение интервалов конец") } + } + + /** + * Разворачивает результат проверки покрытия в GeoJSON-объекты для диагностического слоя. + * Слой нужен только при включенной проверке покрытия интервалов и помогает увидеть части заявки, + * которые не были накрыты построенными интервалами. + */ + private fun createCoverageGapItems( + aplItem: AplObjItem, + revSign: RevolutionSign, + uncoveredGeometry: Geometry + ): List = + collectPolygonalGeometries(uncoveredGeometry).map { geometry -> + GeoJsonItem( + geometry = geometry, + properties = mapOf( + "UUID" to aplItem.id.toString(), + "Name" to aplItem.name, + "RevSign" to revSign.name, + "Area" to geometry.area + ) + ) + } + + /** + * Достает из произвольной JTS-геометрии только полигональные части. + * Coverage-checker может вернуть GeometryCollection, поэтому перед записью слоя результат нормализуется + * до отдельных Polygon/MultiPolygon элементов. + */ + private fun collectPolygonalGeometries(geometry: Geometry): List = + when (geometry) { + is Polygon -> listOf(geometry) + is MultiPolygon -> listOf(geometry) + else -> (0 until geometry.numGeometries).flatMap { collectPolygonalGeometries(geometry.getGeometryN(it)) } + } + + private fun calculateObservationParameters( + parallelObservationParameters: Boolean = parallelObservationParametersEnabled, + observationParametersChunkSizeOverride: Int? = null, + observationParametersParallelismOverride: Int? = null + ) { + val calculationStart = activeCalculationStart ?: scg.first().timeStart + val calculationEnd = activeCalculationEnd ?: scg.first().timeStart.plusDays(aplSettings.settings.daysCalc.toLong()) + if (parallelObservationParameters) { + calculateObservationParametersParallelChunked( + calculationStart, + calculationEnd, + observationParametersChunkSizeOverride, + observationParametersParallelismOverride + ) + } else { + calculateObservationParametersSequential(calculationStart, calculationEnd) + } + } + + /** + * Текущая последовательная реализация расчета МПЛ оставлена отдельным методом без изменения поведения: + * один объект APL-каталога отправляется в баллистику со всеми своими интервалами, следующий объект ждет + * завершения предыдущего. + */ + private fun calculateObservationParametersSequential( + calculationStart: LocalDateTime, + calculationEnd: LocalDateTime + ) { + aplCatalog.filter { it.intevals.isNotEmpty() }.forEach { aplItem -> + val mplCalcRes = calculateMplForIntervals(aplItem.intevals, calculationStart, calculationEnd) + putMplToIntervals(aplItem.intevals, mplCalcRes) + } + } + + /** + * Альтернативный вариант B: интервалы режутся на независимые пачки и считаются в баллистике параллельно + * с ограниченным parallelism. Результаты применяются к interval.mpl только после завершения worker-задач, + * чтобы не мутировать APL-каталог из нескольких потоков. + */ + private fun calculateObservationParametersParallelChunked( + calculationStart: LocalDateTime, + calculationEnd: LocalDateTime, + observationParametersChunkSizeOverride: Int? = null, + observationParametersParallelismOverride: Int? = null + ) { + val chunkSize = (observationParametersChunkSizeOverride ?: observationParametersChunkSize).coerceAtLeast(1) + val chunks = aplCatalog + .flatMap { aplItem -> aplItem.intevals.chunked(chunkSize) } + .filter { it.isNotEmpty() } + if (chunks.isEmpty()) { + return + } + + val parallelism = (observationParametersParallelismOverride ?: observationParametersParallelism) + .coerceAtLeast(1) + .coerceAtMost(chunks.size) + log.info( + "Расчет матрицы выполняется параллельно: chunks={}, chunkSize={}, parallelism={}", + chunks.size, + chunkSize, + parallelism + ) + val executor = Executors.newFixedThreadPool(parallelism) { task -> + Thread(task, "dynamic-plan-mpl-worker").apply { isDaemon = true } + } + try { + val futures = chunks.map { intervals -> + executor.submit { + MplCalculationChunkResult( + intervals = intervals, + mpl = calculateMplForIntervals(intervals, calculationStart, calculationEnd) + ) + } + } + futures.map { future -> + try { + future.get() + } catch (error: ExecutionException) { + val cause = error.cause + if (cause is Exception) { + throw cause + } + throw error + } + }.forEach { result -> + putMplToIntervals(result.intervals, result.mpl) + } + } finally { + executor.shutdownNow() + } + } + + private fun observationParametersChunksCount(observationParametersChunkSizeOverride: Int? = null): Int { + val chunkSize = (observationParametersChunkSizeOverride ?: observationParametersChunkSize).coerceAtLeast(1) + return aplCatalog.sumOf { aplItem -> + val intervalsCount = aplItem.intevals.size + if (intervalsCount == 0) { + 0 + } else { + (intervalsCount + chunkSize - 1) / chunkSize + } + } + } + + private fun calculateMplForIntervals( + intervals: List, + calculationStart: LocalDateTime, + calculationEnd: LocalDateTime + ): List { + if (intervals.isEmpty()) { + return emptyList() + } + + val intervalForMplCalc = ObjViewRequestDTO( + satellites = scg.map { ka -> ka.id }.toList(), + sunAngleMin = -90.0, + timeStart = calculationStart, + timeStop = calculationEnd, + objects = intervals.map { oneInterval -> + ObjDTO( + id = oneInterval.id, + position = PositionDTO(lat = 0.0, long = 0.0, height = 0.0), + contourWKT = oneInterval.geometry.toString() + ) + } + ) + + return ballTask.getMplForObjects(intervalForMplCalc).map { + SquareViewParamDTO( + noradId = it.noradId, + objectId = it.objectId, + revolutionBegin = it.revolutionBegin, + timeBegin = it.timeBegin.minusSeconds(aplSettings.settings.marBeginCorrectionTime), + revolutionEnd = it.revolutionEnd, + revSignBegin = it.revSignBegin, + revSignEnd = it.revSignEnd, + timeEnd = it.timeEnd.plusSeconds(aplSettings.settings.marEndCorrectionTime), + gammaMin = it.gammaMin, + gammaMax = it.gammaMax + ) + } + } + + private fun putMplToIntervals( + intervals: List, + mplCalcRes: List + ) { + val mplByObjectId = mplCalcRes.groupBy { it.objectId } + intervals.forEach { oneInterval -> + oneInterval.mpl.addAll( + mplByObjectId[oneInterval.id.toString()].orEmpty().filter { + it.revSignBegin == oneInterval.revSign || it.revSignEnd == oneInterval.revSign + } + ) + } + } + + /** + * Общая подготовка интервалов и матрицы планирования для обоих путей расчета. + * + * Метод строит интервалы по выбранному направлению витка, затем одним запросом на объект получает МПЛ для + * всех интервалов этого объекта и раскладывает результаты обратно по intervalId. Коррекции времени начала/конца + * применяются сразу здесь, поэтому downstream-код работает уже с фактической длительностью включения. + */ + private fun getIntervalsForAplCatalog( + timings: CalculationTimings? = null, + parallelObservationParameters: Boolean = parallelObservationParametersEnabled, + observationParametersChunkSizeOverride: Int? = null, + observationParametersParallelismOverride: Int? = null, + writeLayers: Boolean = true, + revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH + ) { + val flightLine = scg.first().createFlightLine() + + if (writeLog) { log.info("Построение трассы полета") } + + val revolutionSigns = revolutionSignsForIntervals(revolutionMode) + revolutionSigns.forEach { revSign -> + timings.measure(CalculationTimingStage.INTERVAL_BUILD) { + calcIntervalsForApl(flightLine, revSign = revSign, writeLayers = writeLayers) + } + } + + //тут шаг 5 + //Пройтись по каждому интервалу, получить матрицу планирования + //записать результаты в интервал + if (writeLog) { log.info("Расчет матрицы начало") } + timings.measure(CalculationTimingStage.OBSERVATION_PARAMETERS) { + calculateObservationParameters( + parallelObservationParameters, + observationParametersChunkSizeOverride, + observationParametersParallelismOverride + ) + } + + if (writeLog) { log.info("Расчет матрицы конец") } + aplCatalog.forEach { aplItem -> + aplItem.intevals.forEach { + if (it.mpl.size == 0) { + log.info("Для интервала {} нет наблюдений", it.id) + } + } + } + + if (writeLayers) { + revolutionSigns.forEach { revSign -> + val intervals = aplCatalog.flatMap { aplItem -> aplItem.intevals.filter { it.revSign == revSign } } + geoJsonLayerWriter.write(GeoJsonItem.createIntervalsLay(intervals), revSign.intervalLayerName()) + } + } + + if (writeLog) { log.info("Слой AllIntervals записан") } + } + + private fun RevolutionSign.intervalLayerName(): String = + when (this) { + RevolutionSign.ASC -> "AllIntervalsAsc" + RevolutionSign.DESC -> "AllIntervalsDESC" + } + +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/CaptureAngleCalculator.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/CaptureAngleCalculator.kt new file mode 100644 index 0000000..1086730 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/CaptureAngleCalculator.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp.complan.apl + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import kotlin.math.PI +import kotlin.math.atan +import kotlin.math.sqrt + +class CaptureAngleCalculator { + + fun calculateCaptureAngle(routeWidthKm: Double, points: List): Double? { + if (routeWidthKm <= 0.0 || points.isEmpty()) { + return null + } + + val averageHeightMeters = points + .map { it.orbitRadiusMeters() - EARTH_RADIUS_METERS } + .filter { it > 0.0 } + .average() + + if (averageHeightMeters.isNaN() || averageHeightMeters <= 0.0) { + return null + } + + val halfWidthMeters = routeWidthKm * METERS_IN_KILOMETER / 2.0 + return atan(halfWidthMeters / averageHeightMeters).toDegrees() + } + + private fun OrbPointDTO.orbitRadiusMeters(): Double = sqrt(x * x + y * y + z * z) + + private fun Double.toDegrees(): Double = this * 180.0 / PI + + private companion object { + const val EARTH_RADIUS_METERS = 6_378_136.0 + const val METERS_IN_KILOMETER = 1_000.0 + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/GeoJsonLayerWriter.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/GeoJsonLayerWriter.kt new file mode 100644 index 0000000..dc5447f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/GeoJsonLayerWriter.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.complan.apl + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import space.nstart.pcp.complan.types.GeoJson +import space.nstart.pcp.complan.types.GeoJsonItem + +private val DEFAULT_GEO_JSON_LAYER_OUTPUT_DIR = + "${System.getProperty("java.io.tmpdir")}/pcp-dynamic-plan-service/geo-layers" + +@Service +class GeoJsonLayerWriter( + @param:Value("\${complex-plan.geo-layers.enabled:false}") + private val enabled: Boolean = false, + + @param:Value("\${complex-plan.geo-layers.output-dir:\${java.io.tmpdir}/pcp-dynamic-plan-service/geo-layers}") + private val outputDir: String = DEFAULT_GEO_JSON_LAYER_OUTPUT_DIR +) { + fun write(geoms: List, fileName: String, radToDeg: Boolean = false) { + if (!enabled) { + return + } + + GeoJson.createGeoJson(geoms, fileName, outputDir, radToDeg) + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/IntervalCoverageChecker.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/IntervalCoverageChecker.kt new file mode 100644 index 0000000..3582b46 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/IntervalCoverageChecker.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.complan.apl + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.operation.union.UnaryUnionOp + +class IntervalCoverageChecker { + + fun findUncoveredGeometry(contour: Geometry, intervals: List): Geometry { + if (intervals.isEmpty()) { + return contour.copy() + } + + val intervalsUnion = UnaryUnionOp(intervals).union() + return contour.difference(intervalsUnion) + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/IntervalsBuilder.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/IntervalsBuilder.kt new file mode 100755 index 0000000..2125b1c --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/IntervalsBuilder.kt @@ -0,0 +1,306 @@ +package space.nstart.pcp.complan.apl + +import org.locationtech.jts.geom.* +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.lang.Double.max +import java.lang.Double.min +import kotlin.collections.get +import kotlin.compareTo +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.cos + +/** + * Класс построения интервалов съемки + * Под интервалами съемки полагаются марш + */ +@Service +class IntervalsBuilder(private val writeLog : Boolean = false) { + /** Логирование*/ + private val log = LoggerFactory.getLogger(this::class.java) + /** Фабрика geometry*/ + private val geomFactory = GeometryFactory() + + private val erthEcvator = 111.3213777 * (180 / PI) + + /** + * Создание линии параллельной заданной подвинутой на delX + * @param delX дельта перемещения по оси Х + * @param line эталонная линия + * @return Возвращает линию параллельную эталонной подвинутой на значение delX + */ + private fun createParallelLine( + line: LineString, + delX: Double + ): LineString { + val newCoordinates = line.coordinates.map { + Coordinate(it.x + delX, it.y) + }.toTypedArray() + + return geomFactory.createLineString(newCoordinates) + } + + /** + * Создание полигона параллельного заданному подвинутому на delX + * @param delX дельта перемещения по оси Х + * @param pol эталонная полигон + * @return Возвращает полигон параллельный эталонному подвинутому на значение delX + */ + private fun createParallelPol( + pol: Polygon, + delX: Double + ): Polygon { + val newCoordinates = pol.coordinates.map { + Coordinate(it.x + delX, it.y) + }.toTypedArray() + + return geomFactory.createPolygon(newCoordinates) + } + + /** + * Получение описывающего многоугольника + * @param geom контур геометрии + * @return Возвращает описывающий многоугольник [org.locationtech.jts.geom.Envelope] + */ + private fun getBoxInternal(geom: Geometry) = geom.envelopeInternal + + /** + * Возвращает расстояние в км на определенной широте + * @param distanceKm дистанция в км + * @param lat широта + * @return Возвращает значение в радианах эквивалентное дистанции в километрах на данной широте + */ + private fun getCaptureDistanceInKm(distanceKm: Double, lat: Double) = (distanceKm / (erthEcvator * cos(lat / 180 * PI ))) * 180 / PI //.toRad() + //для градусов, но мы считаем в радианах +// private fun getCaptureDistanceInKm(distanceKm: Double, lat: Double) = (distanceKm / (erthecvator * cos(lat))).toRad() + + /** + * Возвращает одно значение из двух, которое по модулю ближе к 0 + * @param a ервое значение + * @param b второе значение + * @return Возвращает значение по модулю близкое к 0 + */ + private fun closerToZero(a: Double, b: Double) = if(abs(a) < abs(b)) a else b + + /** + * Передвижение маршрута ближе к контуру + * Точность до 1/10 ширины маршрута + * @param line линия + * @param contour контур + * @param offset ширина маршрута + * @return Возвращает маршрут отступающий от контура не более чем на 0,1 ширины маршрута + */ + private fun moveBorderMarToContour( + line: LineString, + contour: Polygon, + offset: Double + ): Polygon { + var line = line + var step = offset + + val leftLine = line + val rightLine = createParallelLine(line, -1 * step) //3 + var pol = geomFactory.createPolygon(createMar(rightLine, leftLine)) + + while (true) { + if (!pol.intersects(contour)) { +// if(i > 150) throw IllegalArgumentException("Бесконечный цикл нахождения граничных лииний") + pol = createParallelPol(pol, step) + } else { + pol = createParallelPol(pol, -step) + if (step == offset / 10) + break + step = step / 10 + } + } + return pol + } + + /** + * Нахождение ширины региона + * @param borderLines граничные линии для контура + * @return Возвращает ширину региона между его граничными линиями + */ + private fun getRegionWidth(borderpols: Pair, offset: Double): Double { + val firstEnvelope = borderpols.first.envelopeInternal + val secondEnvelope = borderpols.second.envelopeInternal + + return (secondEnvelope.maxX - firstEnvelope.maxX) - 1*offset //3 + } + + /** + * Нахождение граничных полигонов + * @param flightLine линия полета + * @param contour контур региона + * @param internalBox описывающий многоугольник + * @param offset ширина маршрута + * @return Возвращает пару полигонов в порядке: **ЛЕВЫЙ**, **ПРАВЫЙ** + */ + private fun findBorderPols( + flightLine: LineString, + contour: Polygon, + internalBox: Envelope, + offset: Double + ): Pair { + val xUp = LineShifter().getPointOnLineY(flightLine, internalBox.maxY) ?: throw IllegalStateException("Ошибка нахождения граничных точек") + val xDown = LineShifter() + .getPointOnLineY(flightLine, internalBox.minY) ?: throw IllegalStateException("Ошибка нахождения граничных точек") + + val minXValue = min(min(internalBox.minX - xUp.x, internalBox.maxX - xUp.x), min(internalBox.minX - xDown.x, internalBox.maxX - xDown.x)) + val maxXValue = max(max(internalBox.minX - xUp.x, internalBox.maxX - xUp.x), max(internalBox.minX - xDown.x, internalBox.maxX - xDown.x)) + + val leftLine = LineShifter().lineShift(flightLine, minXValue) ?: throw IllegalStateException("Ошибка нахождения граничных точек") + val rightLine = LineShifter().lineShift(flightLine, maxXValue) ?: throw IllegalStateException("Ошибка нахождения граничных точек") + //двигаем линии ближе к контуру региона + //первая линия + val firstPol = moveBorderMarToContour(leftLine, contour, offset) + //последняя линия + val lastPol = moveBorderMarToContour(rightLine, contour, -offset) + + if (writeLog) { log.info("Граничные точки найдены") } + + return Pair(firstPol, lastPol) + } + + /** + * Построение интервалов + * @param mars список маршрутов + * @param contour контур региона + * @return Возвращает список интервалов + */ + private fun buildIntervals( + mars: List, + contour: Polygon + ): MutableList { + var intervals = mutableListOf() + for (mar in mars) { + val interval = contour.intersection(mar) + if(interval is Polygon) { + intervals.add(interval) + } + else if(interval is MultiPolygon) { + for (i in 0 until interval.numGeometries) { + intervals.add(interval.getGeometryN(i) as Polygon) + } + } + } + + return intervals + } + + /** + * Создание маршрута + * @param leftLine левая граничная линия маршрута + * @param rightLine правая граничная линия маршрута + * @return Возвращает маршрут заключенный между граничными линиями + */ + private fun createMar( + rightLine: LineString, + leftLine: LineString + ): Array{ + val coordinates = mutableListOf() + + for (j in rightLine.coordinates) + coordinates.add(Coordinate(j.x, j.y)) + + for (j in leftLine.coordinates.size - 1 downTo 0) + coordinates.add(Coordinate(leftLine.coordinates[j].x, leftLine.coordinates[j].y)) + + coordinates.add(Coordinate(rightLine.coordinates[0].x, rightLine.coordinates[0].y)) + return coordinates.toTypedArray() + } + + /** + * Построение маршрутов + * @param width ширина региона + * @param lines граничные линии первого маршрута + * @param offset ширина маршрута + * @param overlap перекрытие маршрутов + * @return Возвращает список маршрутов для покрытия региона + */ + private fun buildMars( + width: Double, + firstPol: Polygon, + offset: Double, + overlap: Double + ): List { + val offsetWithOverlap = offset - overlap + //расчет минимального количества маршрутов для накрытия полигона + var marCount = ((width - offset) / offsetWithOverlap) + 1 + val intMarCount = ceil(marCount) + + //расчет дополнительного перекрытия + val deltaOverlapMars = ((intMarCount * offsetWithOverlap - width) / (intMarCount - 1))//((intMarCount - marCount) / (intMarCount - 1)) * offset + + val marOffset = offsetWithOverlap - deltaOverlapMars // if(deltaOverlapMars > overlap) (offset - deltaOverlapMars) else (offset - overlap) + + var mars = mutableListOf() + var marPol = firstPol + for (i in 0 until intMarCount.toInt()) { + if(i == 0){ + marPol = createParallelPol(firstPol, offset) + } + else { + val step = marOffset + marPol = createParallelPol(marPol, step) + } + + mars.add(marPol) + } + if (writeLog) { log.info("Модель накрытия района выполнена") } + return mars + } + + /** + * Получение интервалов съемки + * @param contour контур региона + * @param flightLine линия полета + * @param offsetKm ширина маршрута в км + * @param overlapKm значение минимального перекрытия в км + * @return Возвращает список интервалов для заданных параметров + */ + fun getIntervals( + contour: Polygon, + flightLine: LineString, + offsetKm: Double, + overlapKm: Double + ): List { + //TODO(Исправить определение граничных точек района. + // При построении интервалов некоторая часть района(граничная слева/справа) пропадает.) + //расчет описывающего прямоугольника + val internalBox = getBoxInternal(contour) + + if (internalBox.maxY > 80.0 || internalBox.minY < -80.0) { + return emptyList() + //TODO("Такой район пока что не обрабатывается, нужно определить способ построения интервалов для таких районов") + } + + //получаем минимальную широту + val minY = closerToZero(internalBox.maxY, internalBox.minY) + + //находим перекрытие и ширину маршрута учитывая широту + val offset = getCaptureDistanceInKm(offsetKm, minY) + val overlap = getCaptureDistanceInKm(overlapKm, minY) + + //если ширина контура не больше чем ширина маршрута - возвращаем контур как интервал + if((internalBox.maxX - internalBox.minX) < offset) { + if (writeLog) { log.info("Контур района менее контура маршрута") } + return listOf(contour) + } + + try { + //ищем граничные линии для контура + val borderPols = findBorderPols(flightLine, contour, internalBox, offset) + + //строим маршруты + val mars = buildMars(getRegionWidth(borderPols, offset), borderPols.first, offset, overlap) + + return buildIntervals(mars, contour) + }catch (e: Exception){ + log.info(e.toString()) + throw e + } + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/LineShifter.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/LineShifter.kt new file mode 100644 index 0000000..ee9ed4e --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/LineShifter.kt @@ -0,0 +1,79 @@ +package space.nstart.pcp.complan.apl + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.CoordinateFilter +import org.locationtech.jts.geom.LineSegment +import org.locationtech.jts.geom.LineString +import kotlin.collections.get + +/** + * Класс для "сдвига" линии на заданную координату по X, не меняя направление сегментов + * Поиск значений координат на заданном отрезке по X и Y + * */ +class LineShifter { + + /** + * Сдвигает линию на заданное значение X + * Если линия состоит менее чем из 2-х точек, возвращает null + * */ + fun lineShift(line : LineString, xValue : Double) : LineString? { + if (line.coordinates.size < 2) { return null } + + val copy = line.copy() as LineString + + // Применяем фильтр, который конвертирует радианы → градусы + copy.apply(CoordinateFilter { coord -> + coord.x = coord.x + xValue // долгота + coord.y = coord.y // широта + }) + + return copy + } + + /** + * Находит первую точку на полилинии, лежащую на заданной координате Y + * Возвращает null, если значение Y лежит вне проекции ее на отрезок + * Например отрезок LineSegment(Coordinate(x=0.0,y=0.0), Coordinate(x=10.0,y=5.0)) + * y должен принадлежать [0.0, 5.0], иначе null + * */ + fun getPointOnLineY(line : LineString, yValue : Double) : Coordinate? { + if (line.coordinates.size < 2) { return null } + for (i in 1.., roll: Double, capture: Double): String? { + val orderedPoints = points.sortedBy { it.time } + if (orderedPoints.size < 2) { + logger.warn("Контур маршрута не построен: недостаточно точек орбиты, points={}", orderedPoints.size) + return null + } + + return try { + val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + val rightCoordinates = ArrayList(orderedPoints.size) + val leftCoordinates = ArrayList(orderedPoints.size) + + orderedPoints.map { it.toOrbitalPoint() }.forEach { point -> + val rightPoint = viewParams(pointOnEarthCalculator, point, roll + capture) + ?: THBLPoint(0.0, 0.0, 0.0, 0.0) + rightCoordinates += Coordinate(rightPoint.long.toDegrees(), rightPoint.lat.toDegrees(), 0.0) + + val leftPoint = viewParams(pointOnEarthCalculator, point, roll - capture) + ?: THBLPoint(0.0, 0.0, 0.0, 0.0) + leftCoordinates += Coordinate(leftPoint.long.toDegrees(), leftPoint.lat.toDegrees(), 0.0) + } + + val shellCoordinates = ArrayList(rightCoordinates.size + leftCoordinates.size + 1) + shellCoordinates.addAll(rightCoordinates) + for (index in leftCoordinates.indices.reversed()) { + shellCoordinates += leftCoordinates[index] + } + if (shellCoordinates.isNotEmpty()) { + shellCoordinates += Coordinate(shellCoordinates.first().x, shellCoordinates.first().y, 0.0) + } + + val distinctVertices = shellCoordinates + .dropLast(1) + .map { it.x to it.y } + .distinct() + if (shellCoordinates.size < 4 || distinctVertices.size < 3) { + logger.warn("Контур маршрута не построен: недостаточно уникальных точек полигона") + return null + } + + val polygon = geometryFactory.createPolygon(shellCoordinates.toTypedArray()) + wktWriter.write(polygon) + } catch (error: Exception) { + logger.warn("Контур маршрута не построен: {}", error.message) + null + } + } + + private fun viewParams( + pointOnEarthCalculator: PointOnEarthCalculator, + point: OrbitalPoint, + gamma: Double + ): THBLPoint? = pointOnEarthCalculator.pointOnEarth(point, Orientation(0.0, gamma.toRadians(), 0.0)) + + private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint( + t = fromDateTime(time), + vit = revolution.toInt(), + r = Vector3D(x, y, z), + v = Vector3D(vx, vy, vz) + ) + + private fun Double.toDegrees(): Double = this * 180.0 / PI + + private fun Double.toRadians(): Double = this * PI / 180.0 +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/config/DynamicPlanExecutorConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/config/DynamicPlanExecutorConfig.kt new file mode 100644 index 0000000..97f8b46 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/config/DynamicPlanExecutorConfig.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.complan.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@Configuration +class DynamicPlanExecutorConfig { + + @Bean(destroyMethod = "shutdown") + fun dynamicPlanExecutor(): ExecutorService = + Executors.newSingleThreadExecutor { task -> + Thread(task, "dynamic-plan-calculation").apply { + isDaemon = false + } + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/config/KafkaConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/config/KafkaConfig.kt new file mode 100644 index 0000000..0d4e102 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/config/KafkaConfig.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.complan.config + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean + fun consumerFactory(): ConsumerFactory { + val props = hashMapOf() + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers + props[ConsumerConfig.GROUP_ID_CONFIG] = groupId + props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "latest" + props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + + return DefaultKafkaConsumerFactory(props) + } + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory() + .apply { setConsumerFactory(consumerFactory()) } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/AplSettingsEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/AplSettingsEntity.kt new file mode 100644 index 0000000..5c36137 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/AplSettingsEntity.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.complan.db.entity + +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Column +import jakarta.persistence.Table + +@Table(name = "aplsettings") +@Entity +class AplSettingsEntity ( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + var id : Int? = null, + + @Column(name = "settings") + var settings : String +) { + constructor() : this(settings = "") +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanIntervalEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanIntervalEntity.kt new file mode 100644 index 0000000..1115e28 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanIntervalEntity.kt @@ -0,0 +1,52 @@ +package space.nstart.pcp.complan.db.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.util.UUID + +@Table(name = "dynamic_plan_interval") +@Entity +class DynamicPlanIntervalEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "run_id", nullable = false) + var run: DynamicPlanRunEntity? = null, + + @Column(name = "request_id", nullable = false) + var requestId: UUID? = null, + + @Column(name = "interval_id", nullable = false) + var intervalId: UUID? = null, + + @Column(name = "region_id", nullable = false) + var regionId: UUID? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "rev_sign", nullable = false) + var revSign: RevolutionSign = RevolutionSign.ASC, + + @Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT") + var contourWkt: String = "", + + @Column(name = "observation_parameters_count", nullable = false) + var observationParametersCount: Int = 0, + + @Column(name = "interval_order", nullable = false) + var intervalOrder: Int = 0 +) { + constructor() : this(id = null) +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt new file mode 100644 index 0000000..259202f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt @@ -0,0 +1,55 @@ +package space.nstart.pcp.complan.db.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Table(name = "dynamic_plan_route") +@Entity +class DynamicPlanRouteEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "run_id", nullable = false) + var run: DynamicPlanRunEntity? = null, + + @Column(name = "request_id", nullable = false) + var requestId: UUID? = null, + + @Column(name = "satellite_id", nullable = false) + var satelliteId: Long = 0, + + @Column(name = "start_time", nullable = false) + var startTime: LocalDateTime = LocalDateTime.now(), + + @Column(name = "end_time", nullable = false) + var endTime: LocalDateTime = LocalDateTime.now(), + + @Column(name = "duration", nullable = false) + var duration: Double = 0.0, + + @Column(name = "revolution", nullable = false) + var revolution: Long = 0, + + @Column(name = "roll", nullable = false) + var roll: Double = 0.0, + + @Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT") + var contourWkt: String = "", + + @Column(name = "route_order", nullable = false) + var routeOrder: Int = 0 +) { + constructor() : this(id = null) +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt new file mode 100644 index 0000000..a307bdb --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt @@ -0,0 +1,74 @@ +package space.nstart.pcp.complan.db.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import space.nstart.pcp.complan.types.DynamicPlanCalculationMode +import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode +import space.nstart.pcp.complan.types.DynamicPlanRunStatus +import java.time.LocalDateTime +import java.util.UUID + +@Table(name = "dynamic_plan_run") +@Entity +class DynamicPlanRunEntity( + @Id + @Column(name = "id", nullable = false) + var id: UUID? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: DynamicPlanRunStatus = DynamicPlanRunStatus.PENDING, + + @Column(name = "request_id", nullable = false) + var requestId: UUID? = null, + + @Column(name = "satellite_ids", nullable = false) + var satelliteIds: String = "[]", + + @Column(name = "calculation_start", nullable = false) + var calculationStart: LocalDateTime? = null, + + @Column(name = "calculation_end", nullable = false) + var calculationEnd: LocalDateTime? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "calculation_mode", nullable = false) + var calculationMode: DynamicPlanCalculationMode = DynamicPlanCalculationMode.FULL, + + @Enumerated(EnumType.STRING) + @Column(name = "revolution_mode", nullable = false) + var revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + + @Column(name = "filter_covered_routes", nullable = false) + var filterCoveredRoutes: Boolean = false, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "started_at") + var startedAt: LocalDateTime? = null, + + @Column(name = "finished_at") + var finishedAt: LocalDateTime? = null, + + @Column(name = "duration_ms") + var durationMs: Long? = null, + + @Column(name = "result_status") + var resultStatus: String? = null, + + @Column(name = "routes_count") + var routesCount: Int? = null, + + @Column(name = "result_json", columnDefinition = "TEXT") + var resultJson: String? = null, + + @Column(name = "error_message", columnDefinition = "TEXT") + var errorMessage: String? = null +) { + constructor() : this(id = null) +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/AplSettingsRepository.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/AplSettingsRepository.kt new file mode 100644 index 0000000..a531d86 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/AplSettingsRepository.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.complan.db.repository + +import space.nstart.pcp.complan.db.entity.AplSettingsEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +@Transactional +interface AplSettingsRepository: JpaRepository { + fun findFirstByOrderByIdDesc() : space.nstart.pcp.complan.db.entity.AplSettingsEntity? +} \ No newline at end of file diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanIntervalRepository.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanIntervalRepository.kt new file mode 100644 index 0000000..1adb66d --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanIntervalRepository.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.complan.db.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import space.nstart.pcp.complan.db.entity.DynamicPlanIntervalEntity +import java.util.UUID + +@Repository +interface DynamicPlanIntervalRepository : JpaRepository { + @Query( + value = """ + select * + from dynamic_plan_interval + where run_id = :runId + order by interval_order asc, id asc + limit :limit offset :offset + """, + nativeQuery = true + ) + fun findIntervalsByRunId( + @Param("runId") runId: UUID, + @Param("limit") limit: Int, + @Param("offset") offset: Int + ): List +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanRouteRepository.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanRouteRepository.kt new file mode 100644 index 0000000..302ce6c --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanRouteRepository.kt @@ -0,0 +1,34 @@ +package space.nstart.pcp.complan.db.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.stereotype.Repository +import space.nstart.pcp.complan.db.entity.DynamicPlanRouteEntity +import java.util.UUID + +@Repository +interface DynamicPlanRouteRepository : JpaRepository { + fun countBySatelliteId(satelliteId: Long): Long + + @Modifying + @Query("delete from DynamicPlanRouteEntity route where route.satelliteId = :satelliteId") + fun deleteAllBySatelliteId(@Param("satelliteId") satelliteId: Long): Int + + @Query( + value = """ + select * + from dynamic_plan_route + where run_id = :runId + order by start_time asc, id asc + limit :limit offset :offset + """, + nativeQuery = true + ) + fun findRoutesByRunId( + @Param("runId") runId: UUID, + @Param("limit") limit: Int, + @Param("offset") offset: Int + ): List +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanRunRepository.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanRunRepository.kt new file mode 100644 index 0000000..72210d4 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/repository/DynamicPlanRunRepository.kt @@ -0,0 +1,74 @@ +package space.nstart.pcp.complan.db.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.stereotype.Repository +import space.nstart.pcp.complan.db.entity.DynamicPlanRunEntity +import space.nstart.pcp.complan.types.DynamicPlanRunStatus +import java.util.UUID + +@Repository +interface DynamicPlanRunRepository : JpaRepository { + + @Query( + value = """ + select count(*) + from dynamic_plan_run run + where exists ( + select 1 + from jsonb_array_elements_text(run.satellite_ids::jsonb) satellite_id(value) + where satellite_id.value::bigint = :satelliteId + ) + """, + nativeQuery = true + ) + fun countBySatelliteId(@Param("satelliteId") satelliteId: Long): Long + + @Modifying + @Query( + value = """ + delete from dynamic_plan_run run + where exists ( + select 1 + from jsonb_array_elements_text(run.satellite_ids::jsonb) satellite_id(value) + where satellite_id.value::bigint = :satelliteId + ) + """, + nativeQuery = true + ) + fun deleteAllBySatelliteId(@Param("satelliteId") satelliteId: Long): Int + + @Query( + value = """ + select * + from dynamic_plan_run + order by created_at desc + limit :limit offset :offset + """, + nativeQuery = true + ) + fun findRuns( + @Param("limit") limit: Int, + @Param("offset") offset: Int + ): List + + @Query( + value = """ + select * + from dynamic_plan_run + where status = :status + order by created_at desc + limit :limit offset :offset + """, + nativeQuery = true + ) + fun findRunsByStatus( + @Param("status") status: String, + @Param("limit") limit: Int, + @Param("offset") offset: Int + ): List + + fun findAllByStatusIn(statuses: Collection): List +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/AplSettingsLoader.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/AplSettingsLoader.kt new file mode 100644 index 0000000..374b68a --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/AplSettingsLoader.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.complan.service + +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import org.springframework.http.HttpStatus +import space.nstart.pcp.complan.db.repository.AplSettingsRepository +import space.nstart.pcp.complan.settings.AplSettings +import space.nstart.pcp.complan.settings.SettingsDTO + +@Service +class AplSettingsLoader( + private val aplSetRep: AplSettingsRepository, + private val aplSettings: AplSettings, + private val objectMapper: ObjectMapper +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @Transactional(readOnly = true) + fun loadLatestSettings() { + val settingsEntity = aplSetRep.findFirstByOrderByIdDesc() + if (settingsEntity == null) { + log.info("APL settings were not found in DB, current in-memory settings will be used") + return + } + + aplSettings.settings = try { + objectMapper.readValue(settingsEntity.settings, SettingsDTO::class.java) + } catch (error: Exception) { + throw ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to read APL settings from DB record id=${settingsEntity.id}", + error + ) + } + log.info("APL settings loaded from DB record id={}", settingsEntity.id) + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt new file mode 100644 index 0000000..3821b91 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt @@ -0,0 +1,98 @@ +package space.nstart.pcp.complan.service + + +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.ParameterizedTypeReference +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import org.springframework.web.util.UriComponentsBuilder +import space.nstart.pcp.complan.types.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO as BallisticsObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO as BallisticsObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.LocalDateTime + +/**Получение данных от сервиса баллистики*/ +@Service +class BallTaskServce { + + + @Value("\${complex-plan.ballistics-service-url}") + private lateinit var ballisticsServiceUrl: String + + fun getOrbitAvailability(noradId : Long): SatelliteOrbitAvailabilityDTO = + RestClient.create() + .get() + .uri(buildBallisticsUri("/api/satellites/orbit/availability/$noradId")) + .retrieve() + .body(SatelliteOrbitAvailabilityDTO::class.java) + ?: throw IllegalStateException("Пустой ответ баллистики для доступности ПДЦМ КА $noradId") + + fun getFlightLine(noradId: Long, timeStart : LocalDateTime, timeStop : LocalDateTime): List = + RestClient.create() + .get() + .uri( + UriComponentsBuilder + .fromUriString(ballisticsServiceUrl) + .path("/api/satellites/$noradId/flight-line") + .queryParam("time_start", timeStart) + .queryParam("time_stop", timeStop) + .toUriString() + ) + .retrieve() + .body(FLIGHT_LINE_LIST_TYPE) + .orEmpty() + + fun getExactTime(noradId: Long, request: ExactTimePositionRequestDTO): List = + RestClient.create() + .post() + .uri(buildBallisticsUri("/api/satellites/$noradId/extract-time")) + .body(request) + .retrieve() + .body(ORB_POINT_LIST_TYPE) + .orEmpty() + + fun getMplForObjects(objects : ObjViewRequestDTO): List { + val satellites = objects.satellites?.toList().orEmpty() + val timeStart = objects.timeStart ?: throw IllegalArgumentException("timeStart must be specified for MPL request") + val timeStop = objects.timeStop ?: throw IllegalArgumentException("timeStop must be specified for MPL request") + + return objects.objects.flatMap { obj -> + RestClient.create() + .post() + .uri(buildBallisticsUri("/api/obj-view/mpl-square")) + .body( + BallisticsObjViewRequestDTO( + satellites = satellites, + timeStart = timeStart, + timeStop = timeStop, + obj = BallisticsObjDTO( + id = obj.id.toString(), + position = obj.position, + contourWKT = obj.contourWKT + ), + sunAngleMin = objects.sunAngleMin + ) + ) + .retrieve() + .body(SQUARE_VIEW_PARAM_LIST_TYPE) + .orEmpty() + } + } + + private fun buildBallisticsUri(path: String): String = + UriComponentsBuilder + .fromUriString(ballisticsServiceUrl) + .path(path) + .toUriString() + + private companion object { + val FLIGHT_LINE_LIST_TYPE = object : ParameterizedTypeReference>() {} + val ORB_POINT_LIST_TYPE = object : ParameterizedTypeReference>() {} + val SQUARE_VIEW_PARAM_LIST_TYPE = object : ParameterizedTypeReference>() {} + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt new file mode 100644 index 0000000..3afa0da --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt @@ -0,0 +1,324 @@ +package space.nstart.pcp.complan.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.complan.apl.BasicPlanCalculator +import space.nstart.pcp.complan.db.entity.DynamicPlanIntervalEntity +import space.nstart.pcp.complan.db.entity.DynamicPlanRouteEntity +import space.nstart.pcp.complan.db.entity.DynamicPlanRunEntity +import space.nstart.pcp.complan.db.repository.DynamicPlanIntervalRepository +import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository +import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository +import space.nstart.pcp.complan.types.ComplexPlanCalculationOutput +import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO +import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO +import space.nstart.pcp.complan.types.DynamicPlanIntervalDTO +import space.nstart.pcp.complan.types.DynamicPlanRouteDTO +import space.nstart.pcp.complan.types.DynamicPlanRunDTO +import space.nstart.pcp.complan.types.DynamicPlanRunStatus +import space.nstart.pcp.complan.types.IntervalItemDTO +import space.nstart.pcp.complan.types.SurveyContour +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.ExecutorService +import kotlin.math.roundToLong + +@Service +class DynamicPlanRunService( + private val runRepository: DynamicPlanRunRepository, + private val routeRepository: DynamicPlanRouteRepository, + private val intervalRepository: DynamicPlanIntervalRepository, + private val basePlan: BasicPlanCalculator, + private val aplSettingsLoader: AplSettingsLoader, + private val objectMapper: ObjectMapper, + private val transactionTemplate: TransactionTemplate, + private val dynamicPlanExecutor: ExecutorService +) { + private val log = LoggerFactory.getLogger(this::class.java) + private val satelliteIdsType = object : TypeReference>() {} + + fun startCalculation(request: ComplexPlanCalculationRequestDTO): DynamicPlanRunDTO { + validateRequest(request) + + val runId = UUID.randomUUID() + val createdRun = transactionTemplate.execute { + runRepository.save( + DynamicPlanRunEntity( + id = runId, + status = DynamicPlanRunStatus.PENDING, + requestId = request.requestId, + satelliteIds = objectMapper.writeValueAsString(request.satelliteIds.distinct()), + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + calculationMode = request.calculationMode, + revolutionMode = request.revolutionMode, + filterCoveredRoutes = request.filterCoveredRoutes, + createdAt = LocalDateTime.now() + ) + ) + } ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run") + + dynamicPlanExecutor.submit { + executeCalculation(runId) + } + + return createdRun.toDto() + } + + fun getRun(runId: UUID): DynamicPlanRunDTO = + findRun(runId).toDto() + + fun listRuns(status: DynamicPlanRunStatus?, limit: Int, offset: Int): List { + validatePage(limit, offset) + val runs = when (status) { + null -> runRepository.findRuns(limit, offset) + else -> runRepository.findRunsByStatus(status.name, limit, offset) + } + return runs.map { run -> run.toDto() } + } + + fun getResult(runId: UUID): ComplexPlanCalculationResultDTO { + val run = findRun(runId) + if (run.status != DynamicPlanRunStatus.COMPLETED) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId is not completed") + } + + val resultJson = run.resultJson + ?: throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId has no result") + + val resultNode = objectMapper.readTree(resultJson) + if (resultNode is ObjectNode && !resultNode.has("calculationMode")) { + resultNode.put("calculationMode", run.calculationMode.name) + } + if (resultNode is ObjectNode && !resultNode.has("revolutionMode")) { + resultNode.put("revolutionMode", run.revolutionMode.name) + } + if (resultNode is ObjectNode && !resultNode.has("filterCoveredRoutes")) { + resultNode.put("filterCoveredRoutes", run.filterCoveredRoutes) + } + + return objectMapper.treeToValue(resultNode, ComplexPlanCalculationResultDTO::class.java) + } + + fun getRoutes(runId: UUID, limit: Int, offset: Int): List { + validatePage(limit, offset) + val run = findRun(runId) + if (run.status != DynamicPlanRunStatus.COMPLETED) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId is not completed") + } + return routeRepository.findRoutesByRunId(runId, limit, offset) + .map { route -> route.toDto() } + } + + fun getIntervals(runId: UUID, limit: Int, offset: Int): List { + validatePage(limit, offset) + val run = findRun(runId) + if (run.status != DynamicPlanRunStatus.COMPLETED) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId is not completed") + } + return intervalRepository.findIntervalsByRunId(runId, limit, offset) + .map { interval -> interval.toDto() } + } + + fun recoverInterruptedRuns() { + val recoveredCount = transactionTemplate.execute { + val runs = runRepository.findAllByStatusIn( + listOf(DynamicPlanRunStatus.PENDING, DynamicPlanRunStatus.RUNNING) + ) + if (runs.isEmpty()) { + return@execute 0 + } + + val finishedAt = LocalDateTime.now() + runs.forEach { run -> + run.status = DynamicPlanRunStatus.FAILED + run.finishedAt = finishedAt + run.errorMessage = "Calculation was interrupted by service restart" + } + runRepository.saveAll(runs) + runs.size + } ?: 0 + + if (recoveredCount > 0) { + log.warn("Recovered {} interrupted dynamic plan runs after startup", recoveredCount) + } + } + + private fun executeCalculation(runId: UUID) { + val request = markRunningAndBuildRequest(runId) + if (request == null) { + log.warn("Dynamic plan run {} was not found before execution", runId) + return + } + + try { + aplSettingsLoader.loadLatestSettings() + val output = basePlan.calculate(request) + markCompleted(runId, output) + } catch (error: Exception) { + log.error("Dynamic plan run {} failed", runId, error) + markFailed(runId, error) + } + } + + private fun markRunningAndBuildRequest(runId: UUID): ComplexPlanCalculationRequestDTO? = + transactionTemplate.execute { + val run = runRepository.findById(runId).orElse(null) ?: return@execute null + run.status = DynamicPlanRunStatus.RUNNING + run.startedAt = LocalDateTime.now() + run.errorMessage = null + runRepository.save(run) + run.toRequest() + } + + private fun markCompleted(runId: UUID, output: ComplexPlanCalculationOutput) { + transactionTemplate.execute { + val run = runRepository.findById(runId).orElseThrow() + val result = output.result + run.status = DynamicPlanRunStatus.COMPLETED + run.finishedAt = LocalDateTime.now() + run.durationMs = result.durationMs + run.resultStatus = result.status.name + run.routesCount = result.routesCount + run.resultJson = objectMapper.writeValueAsString(result) + run.errorMessage = null + intervalRepository.saveAll( + output.intervals.mapIndexed { index, interval -> interval.toEntity(run, result.requestId, index) } + ) + routeRepository.saveAll(output.routes.mapIndexed { index, route -> route.toEntity(run, result.requestId, index) }) + runRepository.save(run) + } + } + + private fun markFailed(runId: UUID, error: Exception) { + transactionTemplate.execute { + val run = runRepository.findById(runId).orElseThrow() + run.status = DynamicPlanRunStatus.FAILED + run.finishedAt = LocalDateTime.now() + run.errorMessage = error.message ?: error::class.java.simpleName + runRepository.save(run) + } + } + + private fun findRun(runId: UUID): DynamicPlanRunEntity = + runRepository.findById(runId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Calculation $runId was not found") } + + private fun validateRequest(request: ComplexPlanCalculationRequestDTO) { + if (request.calculationEnd <= request.calculationStart) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart") + } + if (request.satelliteIds.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty") + } + } + + private fun validatePage(limit: Int, offset: Int) { + if (limit !in 1..10_000) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "limit must be between 1 and 10000") + } + if (offset < 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "offset must be greater than or equal to 0") + } + } + + private fun DynamicPlanRunEntity.toRequest(): ComplexPlanCalculationRequestDTO = + ComplexPlanCalculationRequestDTO( + requestId = requestId ?: throw IllegalStateException("Run $id has no requestId"), + satelliteIds = readSatelliteIds(satelliteIds), + calculationStart = calculationStart ?: throw IllegalStateException("Run $id has no calculationStart"), + calculationEnd = calculationEnd ?: throw IllegalStateException("Run $id has no calculationEnd"), + calculationMode = calculationMode, + revolutionMode = revolutionMode, + filterCoveredRoutes = filterCoveredRoutes + ) + + private fun DynamicPlanRunEntity.toDto(): DynamicPlanRunDTO = + DynamicPlanRunDTO( + runId = id ?: throw IllegalStateException("Run has no id"), + status = status, + requestId = requestId ?: throw IllegalStateException("Run $id has no requestId"), + satelliteIds = readSatelliteIds(satelliteIds), + calculationStart = calculationStart ?: throw IllegalStateException("Run $id has no calculationStart"), + calculationEnd = calculationEnd ?: throw IllegalStateException("Run $id has no calculationEnd"), + calculationMode = calculationMode, + revolutionMode = revolutionMode, + filterCoveredRoutes = filterCoveredRoutes, + createdAt = createdAt, + startedAt = startedAt, + finishedAt = finishedAt, + durationMs = durationMs, + resultStatus = resultStatus, + routesCount = routesCount, + errorMessage = errorMessage + ) + + private fun readSatelliteIds(value: String): List = + objectMapper.readValue(value, satelliteIdsType) + + private fun IntervalItemDTO.toEntity( + run: DynamicPlanRunEntity, + requestId: UUID, + intervalOrder: Int + ): DynamicPlanIntervalEntity = + DynamicPlanIntervalEntity( + run = run, + requestId = requestId, + intervalId = id, + regionId = regionId, + revSign = revSign, + contourWkt = geometry, + observationParametersCount = mpl.size, + intervalOrder = intervalOrder + ) + + private fun SurveyContour.toEntity( + run: DynamicPlanRunEntity, + requestId: UUID, + routeOrder: Int + ): DynamicPlanRouteEntity = + DynamicPlanRouteEntity( + run = run, + requestId = requestId, + satelliteId = mpl.noradId, + startTime = time, + endTime = time.plusNanos((duration * 1_000_000_000.0).roundToLong().coerceAtLeast(1L)), + duration = duration, + revolution = rev, + roll = gamma, + contourWkt = geometry, + routeOrder = routeOrder + ) + + private fun DynamicPlanIntervalEntity.toDto(): DynamicPlanIntervalDTO = + DynamicPlanIntervalDTO( + id = id ?: throw IllegalStateException("Interval has no id"), + runId = run?.id ?: throw IllegalStateException("Interval $id has no run id"), + requestId = requestId ?: throw IllegalStateException("Interval $id has no request id"), + intervalId = intervalId ?: throw IllegalStateException("Interval $id has no interval id"), + regionId = regionId ?: throw IllegalStateException("Interval $id has no region id"), + revSign = revSign, + contourWkt = contourWkt, + observationParametersCount = observationParametersCount + ) + + private fun DynamicPlanRouteEntity.toDto(): DynamicPlanRouteDTO = + DynamicPlanRouteDTO( + id = id ?: throw IllegalStateException("Route has no id"), + runId = run?.id ?: throw IllegalStateException("Route $id has no run id"), + requestId = requestId ?: throw IllegalStateException("Route $id has no request id"), + satelliteId = satelliteId, + startTime = startTime, + endTime = endTime, + duration = duration, + revolution = revolution, + roll = roll, + contourWkt = contourWkt + ) +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunStartupRecovery.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunStartupRecovery.kt new file mode 100644 index 0000000..74780b7 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunStartupRecovery.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.complan.service + +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component + +@Component +class DynamicPlanRunStartupRecovery( + private val dynamicPlanRunService: DynamicPlanRunService +) : ApplicationRunner { + + override fun run(args: ApplicationArguments) { + dynamicPlanRunService.recoverInterruptedRuns() + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt new file mode 100644 index 0000000..020de0c --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt @@ -0,0 +1,87 @@ +package space.nstart.pcp.complan.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestClient +import org.springframework.web.util.UriComponentsBuilder +import space.nstart.pcp.complan.types.RequestItemDTO +import java.time.OffsetDateTime +import java.util.UUID + +/** Получение заявок из pcp-request-service /v1 API. */ +@Service +class RequestServiceClient { + @Value("\${complex-plan.request-service-url}") + private lateinit var requestServiceUrl: String + + @Value("\${complex-plan.request-selection-path:/v1/requests}") + private lateinit var requestSelectionPath: String + + fun getRequest(requestId: UUID): RequestItemDTO? = + try { + RestClient.create() + .get() + .uri( + UriComponentsBuilder + .fromUriString(requestServiceUrl) + .path(requestSelectionPath) + .path("/{requestId}") + .buildAndExpand(requestId) + .toUriString() + ) + .retrieve() + .body(RequestResponseDto::class.java) + ?.toRequestItemDto() + } catch (_: HttpClientErrorException.NotFound) { + null + } + + private fun RequestResponseDto.toRequestItemDto(): RequestItemDTO = + RequestItemDTO( + id = id, + name = name, + geometry = geometry, + intervalBegin = beginDateTime.toLocalDateTime(), + intervalEnd = endDateTime.toLocalDateTime(), + resolution = resolution(), + importance = importance, + ) + + private fun RequestResponseDto.resolution(): Double = + optics?.resolution + ?: rsa?.resolution + ?: throw IllegalStateException("Request $id has no survey resolution") +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestResponseDto( + val id: UUID, + val name: String, + val status: String = "", + val surveyType: String = "", + val geometry: String, + val importance: Double, + val beginDateTime: OffsetDateTime, + val endDateTime: OffsetDateTime, + val kpp: List = emptyList(), + val highPriorityTransmit: Boolean = false, + val optics: SurveyParamsDto? = null, + val rsa: SurveyParamsDto? = null, + val coverage: CoverageStateDto = CoverageStateDto(), + val createdAt: OffsetDateTime? = null, + val updatedAt: OffsetDateTime? = null, + val deletedAt: OffsetDateTime? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class SurveyParamsDto( + val resolution: Double, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CoverageStateDto( + val requiredPercent: Double? = null, + val currentPercent: Double = 0.0, +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteCatalogServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteCatalogServiceClient.kt new file mode 100644 index 0000000..22f9ff5 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteCatalogServiceClient.kt @@ -0,0 +1,33 @@ +package space.nstart.pcp.complan.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.ParameterizedTypeReference +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import org.springframework.web.util.UriComponentsBuilder +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO + +/** Получение данных КА из satellite-catalog-service. */ +@Service +class SatelliteCatalogServiceClient { + @Value("\${complex-plan.satellite-catalog-service-url}") + private lateinit var satelliteCatalogServiceUrl: String + + fun getSatellites(): List = + RestClient.create() + .get() + .uri(buildSatelliteCatalogUri("/api/satellites")) + .retrieve() + .body(SATELLITE_SUMMARY_LIST_TYPE) + .orEmpty() + + private fun buildSatelliteCatalogUri(path: String): String = + UriComponentsBuilder + .fromUriString(satelliteCatalogServiceUrl) + .path(path) + .toUriString() + + private companion object { + val SATELLITE_SUMMARY_LIST_TYPE = object : ParameterizedTypeReference>() {} + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedKafkaListener.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedKafkaListener.kt new file mode 100644 index 0000000..04d7dd0 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedKafkaListener.kt @@ -0,0 +1,72 @@ +package space.nstart.pcp.complan.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent + +private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-dynamic-plan-service-satellite-deleted" + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class SatelliteDeletedKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteDeletedService: SatelliteDeletedService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + topics = ["\${app.kafka.topics.satellites:pcp.satellites}"], + groupId = SATELLITE_DELETED_CONSUMER_GROUP + ) + fun consume(message: String) { + try { + val kafkaMessage = objectMapper.readValue( + message, + object : TypeReference>() {} + ) + if (kafkaMessage.type != PcpKafkaEvent.SatelliteDeletedEvent) { + logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type) + return + } + + val event = kafkaMessage.data + logger.info( + "Received SatelliteDeletedEvent in pcp-dynamic-plan-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + event.deleteIdentifiers().forEach { satelliteId -> + satelliteDeletedService.deleteSatelliteData(satelliteId) + } + logger.info( + "SatelliteDeletedEvent processed in pcp-dynamic-plan-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + } catch (exception: Exception) { + logger.error("Failed to process SatelliteDeletedEvent in pcp-dynamic-plan-service", exception) + throw exception + } + } + + private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List = + listOfNotNull(satelliteId, noradId).distinct() +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedService.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedService.kt new file mode 100644 index 0000000..a24260f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedService.kt @@ -0,0 +1,49 @@ +package space.nstart.pcp.complan.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository +import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository + +@Service +class SatelliteDeletedService( + private val runRepository: DynamicPlanRunRepository, + private val routeRepository: DynamicPlanRouteRepository +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun deleteSatelliteData(satelliteId: Long): SatelliteDynamicPlanDeletionSummary { + val runsBeforeDelete = runRepository.countBySatelliteId(satelliteId) + val routesBeforeDelete = routeRepository.countBySatelliteId(satelliteId) + + val runsDeleted = runRepository.deleteAllBySatelliteId(satelliteId) + val orphanRoutesDeleted = routeRepository.deleteAllBySatelliteId(satelliteId) + val summary = SatelliteDynamicPlanDeletionSummary( + satelliteId = satelliteId, + runsDeleted = runsDeleted, + routesDeletedByRunCascade = routesBeforeDelete - orphanRoutesDeleted, + orphanRoutesDeleted = orphanRoutesDeleted + ) + + logger.info( + "Deleted dynamic plan data for satellite: satelliteId={}, runsDeleted={}, runsBeforeDelete={}, routesDeletedByRunCascade={}, orphanRoutesDeleted={}, routesBeforeDelete={}", + summary.satelliteId, + summary.runsDeleted, + runsBeforeDelete, + summary.routesDeletedByRunCascade, + summary.orphanRoutesDeleted, + routesBeforeDelete + ) + return summary + } +} + +data class SatelliteDynamicPlanDeletionSummary( + val satelliteId: Long, + val runsDeleted: Int, + val routesDeletedByRunCascade: Long, + val orphanRoutesDeleted: Int +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/settings/AplSettings.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/settings/AplSettings.kt new file mode 100644 index 0000000..43fe41c --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/settings/AplSettings.kt @@ -0,0 +1,37 @@ +package space.nstart.pcp.complan.settings + +import org.springframework.stereotype.Service + +@Service +/** Класс хранения настроек автомата планирования */ +class AplSettings() { + /** Типовые настройки работы автомата планирования */ + var settings = _root_ide_package_.space.nstart.pcp.complan.settings.SettingsDTO() +} + +/* Типовой набор настроек + { + "daysCalc": 1, + "kaList": [ + 1, + 2, + 3 + ], + "marBeginCorrectionTime": 1, + "marEndCorrectionTime": 1, + "marWidth": 30, + "marOverlap": 1, + "marMaxLength": 60, + "marMinLength": 5, + "maxRevTime": 300, + "maxDayTime": 2100, + "contourStepBuild": 1, + "filterByRegionUUID": true, + "regionList": ["831abc95-b3c2-4aec-96d7-7023e5256042"], + "filterByGamma": true, + "gammaValue": 1 +} +Пример записи в БД +"{""daysCalc"":10,""kaList"":[1,2,3],""marBeginCorrectionTime"":1,""marEndCorrectionTime"":1,""marWidth"":30.0,""marOverlap"":1.0,""marMaxLength"":60.0,""marMinLength"":5.0,""maxRevTime"":300.0,""maxDayTime"":2100.0,""contourStepBuild"":1.0,""filterByRegionUUID"":true,""regionList"":[""3fa85f64-5717-4562-b3fc-111111111111""],""filterByGamma"":false,""gammaValue"":6.0,""filterByRevolution"":true,""revolutionType"":0}" + +* */ diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/settings/SettingsDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/settings/SettingsDTO.kt new file mode 100644 index 0000000..5e4b638 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/settings/SettingsDTO.kt @@ -0,0 +1,83 @@ +package space.nstart.pcp.complan.settings + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +@Schema(description = "Типовые настройки работы автомата планирования") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class SettingsDTO( + + @get:JsonProperty("daysCalc") + @param:JsonProperty("daysCalc") + val daysCalc: Int = 1, + + @get:JsonProperty("kaList") + @param:JsonProperty("kaList") + val kaList: Set = setOf(1,2,3), + + @get:JsonProperty("marBeginCorrectionTime") + @param:JsonProperty("marBeginCorrectionTime") + val marBeginCorrectionTime: Long = 1, + + @get:JsonProperty("marEndCorrectionTime") + @param:JsonProperty("marEndCorrectionTime") + val marEndCorrectionTime: Long = 1, + + @get:JsonProperty("marWidth") + @param:JsonProperty("marWidth") + val marWidth : Double = 30.0, + + @get:JsonProperty("marOverlap") + @param:JsonProperty("marOverlap") + val marOverlap : Double = 1.0, + + @get:JsonProperty("marMaxLength") + @param:JsonProperty("marMaxLength") + val marMaxLength : Double = 60.0, + + @get:JsonProperty("marMinLength") + @param:JsonProperty("marMinLength") + val marMinLength : Double = 5.0, + + @get:JsonProperty("maxRevTime") + @param:JsonProperty("maxRevTime") + val maxRevTime : Double = 300.0, + + @get:JsonProperty("maxDayTime") + @param:JsonProperty("maxDayTime") + val maxDayTime : Double = 2100.0, + + @get:JsonProperty("contourStepBuild") + @param:JsonProperty("contourStepBuild") + val contourStepBuild : Double = 1.0, + + @get:JsonProperty("filterByRegionUUID") + @param:JsonProperty("filterByRegionUUID") + val filterByRegionUUID : Boolean = false, + + @get:JsonProperty("regionList") + @param:JsonProperty("regionList") + val regionList : Set = setOf(), + + + @get:JsonProperty("filterByGamma") + @param:JsonProperty("filterByGamma") + val filterByGamma : Boolean = false, + + @get:JsonProperty("gammaValue") + @param:JsonProperty("gammaValue") + val gammaValue : Double = 40.0, + + @get:JsonProperty("filterByRevolution") + @param:JsonProperty("filterByRevolution") + val filterByRevolution : Boolean = false, + + @get:JsonProperty("revolutionType") + @param:JsonProperty("revolutionType") + val revolutionType : Int = 0, + +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SCConstraints.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SCConstraints.kt new file mode 100644 index 0000000..6a5f4d3 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SCConstraints.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.complan.spacecraft + +/** + * Ограничения работы одного космического аппарата при построении расписания включений. + * + * Сейчас модель содержит только временные лимиты, которые использует greedy-планировщик: + * суммарное время работы на витке, суммарное время работы за сутки и технологический разрыв + * между двумя включениями одного аппарата. Класс вынесен рядом со SpaceCraft, чтобы будущие + * ограничения аппарата добавлялись к доменной модели КА, а не разрастались внутри алгоритма планирования. + */ +data class SCConstraints( + val maxRevolutionDurationSeconds: Long = 300, + val maxDailyDurationSeconds: Long = 2100, + val routeGapSeconds: Long = 60 +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt new file mode 100755 index 0000000..853aeec --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt @@ -0,0 +1,144 @@ +package space.nstart.pcp.complan.spacecraft + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.LineString +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +/** + * Класс космического аппарата** + */ +class SpaceCraft ( + val id : Long, + val constraints: SCConstraints = SCConstraints() +) { + val flightLine : MutableList = mutableListOf() + + /**Время начала планирования аппарата*/ + var timeStart: LocalDateTime = LocalDateTime.now() + /**Время конца планирования аппарата*/ + var timeStop : LocalDateTime = LocalDateTime.now() +/** + * Создание линий полета восходящего и нисходящего витка + * @param geomFactory фабрика геометрии + * @param fLC координаты линии + * @return + * @return Возвращает геометрию линии полученной из массива координат + */ + fun createLineString( + geomFactory: GeometryFactory, + fLC: MutableList, + ): LineString { + require(fLC.size >= 2) { "Для построения трассы требуется минимум две точки" } + + val coordinates = fLC.toTypedArray() + val minY = coordinates.minOf { it.y } + val maxY = coordinates.maxOf { it.y } + + val startIndex1 = coordinates.indexOfFirst { it.y == minY } + val endIndex1 = coordinates.indexOfFirst { it.y == maxY } + + val array = if (startIndex1 < endIndex1) + coordinates.sliceArray(startIndex1..endIndex1).toMutableList() + else + coordinates.sliceArray(endIndex1..startIndex1).toMutableList() + + val geom = geomFactory.createLineString(array.toTypedArray()) + + return geom + } + + /** + * Получение линии полета в формате LineString + * @return Возвращает часть линии полета от минимального занчения координаты Y до максимального. Параметры: **первый - НИСХОДЯЩИЙ, второй - ВОСХОДЯЩИЙ** + * TODO Переписать структуру хранения FlightLine, неудобно держать в памяти первый или второй, нужны признаки + */ + fun createFlightLine(): Pair { + val geomFactory = GeometryFactory() + val passes = getFlightLinePasses() + val fLC1 = lineCorrection(selectPass(passes, RevolutionSign.DESC).coordinates).toMutableList() + val fLC2 = lineCorrection(selectPass(passes, RevolutionSign.ASC).coordinates).toMutableList() + + return Pair(createLineString(geomFactory, fLC1), createLineString(geomFactory, fLC2)) + } + + private fun getFlightLinePasses(): List { + if (flightLine.isEmpty()) { + return emptyList() + } + + val passes = mutableListOf() + var currentSign = flightLine.first().revSign + var currentCoordinates = mutableListOf() + + flightLine.forEach { point -> + if (point.revSign != currentSign) { + addPass(passes, currentSign, currentCoordinates) + currentSign = point.revSign + currentCoordinates = mutableListOf() + } + currentCoordinates.add(Coordinate(point.long, point.lat)) + } + addPass(passes, currentSign, currentCoordinates) + + return passes + } + + private fun addPass( + passes: MutableList, + revSign: RevolutionSign, + coordinates: List + ) { + if (coordinates.size >= 2) { + passes.add(FlightLinePass(revSign, coordinates.map { it.copy() })) + } + } + + private fun selectPass(passes: List, revSign: RevolutionSign): FlightLinePass { + val sameDirectionPasses = passes.filter { it.revSign == revSign } + return sameDirectionPasses.firstOrNull { it.isFullPass() } + ?: sameDirectionPasses.maxByOrNull { it.latitudeRange } + ?: throw IllegalStateException("Не найдена трасса полета для направления $revSign") + } + + fun lineCorrection(tmpLine : List) : List { + if (tmpLine.isEmpty()) { + return emptyList() + } + + val line = mutableListOf() + + line.add(tmpLine.first().copy()) + for (i in 1.. HALF_WORLD_DEGREES) { + coord.x -= FULL_WORLD_DEGREES + } + while (coord.x - line.last().x < -HALF_WORLD_DEGREES) { + coord.x += FULL_WORLD_DEGREES + } + line.add(coord.copy()) + } + return line + } + + private data class FlightLinePass( + val revSign: RevolutionSign, + val coordinates: List + ) { + val minLat = coordinates.minOf { it.y } + val maxLat = coordinates.maxOf { it.y } + val latitudeRange = maxLat - minLat + + fun isFullPass(): Boolean = minLat <= FULL_PASS_MIN_LAT && maxLat >= FULL_PASS_MAX_LAT + } + + private companion object { + const val FULL_PASS_MIN_LAT = -80.0 + const val FULL_PASS_MAX_LAT = 80.0 + const val HALF_WORLD_DEGREES = 180.0 + const val FULL_WORLD_DEGREES = 360.0 + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/AplObjItem.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/AplObjItem.kt new file mode 100644 index 0000000..ade0361 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/AplObjItem.kt @@ -0,0 +1,64 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.io.WKTReader +import java.time.LocalDateTime +import java.util.* + +@Schema(description = "Объект каталога для планирования") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class AplObjItem( + + @get:JsonProperty("name") + @param:JsonProperty("name") + val name: String, + + @get:JsonProperty("geometry") + @param:JsonProperty("geometry") + val geometry: Geometry, + + @get:JsonProperty("id") + @param:JsonProperty("id") + val id : UUID, + + @get:JsonProperty("intevals") + @param:JsonProperty("intevals") + @JsonIgnore + val intevals : MutableList, + + @get:JsonProperty("krenMin") + @param:JsonProperty("krenMin") + val krenMin : Double, + + @get:JsonProperty("krenMax") + @param:JsonProperty("krenMax") + val krenMax : Double, + + @get:JsonProperty("intervalBegin") + @param:JsonProperty("intervalBegin") + val intervalBegin : LocalDateTime? = null, + + @get:JsonProperty("intervalEnd") + @param:JsonProperty("intervalEnd") + val intervalEnd : LocalDateTime? = null, + + @get:JsonProperty("lastChange") + @param:JsonProperty("lastChange") + /** Если значение данного параметра null, значит задание новое */ + val lastChange : LocalDateTime? = null, +) { + constructor(obj: RequestItemDTO) : this( + name = obj.name, + geometry = WKTReader().read(obj.geometry), + id = obj.id, + intevals = mutableListOf(), + krenMin = -45.0, + krenMax = 45.0 + ) +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationOutput.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationOutput.kt new file mode 100644 index 0000000..ffabd58 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationOutput.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.complan.types + +data class ComplexPlanCalculationOutput( + val result: ComplexPlanCalculationResultDTO, + val routes: List, + val intervals: List = emptyList() +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationRequestDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationRequestDTO.kt new file mode 100644 index 0000000..bdbf9a0 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationRequestDTO.kt @@ -0,0 +1,39 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +@Schema(description = "Запрос расчета модели комплексного плана для одной заявки") +@JsonIgnoreProperties(ignoreUnknown = true) +data class ComplexPlanCalculationRequestDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("calculationMode") + @param:JsonProperty("calculationMode") + val calculationMode: DynamicPlanCalculationMode = DynamicPlanCalculationMode.FULL, + + @get:JsonProperty("revolutionMode") + @param:JsonProperty("revolutionMode") + val revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + + @get:JsonProperty("filterCoveredRoutes") + @param:JsonProperty("filterCoveredRoutes") + val filterCoveredRoutes: Boolean = false +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationResultDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationResultDTO.kt new file mode 100644 index 0000000..4433ae8 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ComplexPlanCalculationResultDTO.kt @@ -0,0 +1,73 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +enum class ComplexPlanCalculationStatus { + COMPLETED, + REQUEST_NOT_FOUND, + NO_AVAILABLE_SATELLITES +} + +@Schema(description = "Результат запуска расчета модели комплексного плана") +@JsonIgnoreProperties(ignoreUnknown = true) +data class ComplexPlanCalculationResultDTO( + @get:JsonProperty("status") + @param:JsonProperty("status") + val status: ComplexPlanCalculationStatus, + + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("missingSatelliteIds") + @param:JsonProperty("missingSatelliteIds") + val missingSatelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("calculationMode") + @param:JsonProperty("calculationMode") + val calculationMode: DynamicPlanCalculationMode = DynamicPlanCalculationMode.FULL, + + @get:JsonProperty("revolutionMode") + @param:JsonProperty("revolutionMode") + val revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + + @get:JsonProperty("filterCoveredRoutes") + @param:JsonProperty("filterCoveredRoutes") + val filterCoveredRoutes: Boolean = false, + + @get:JsonProperty("routesCount") + @param:JsonProperty("routesCount") + val routesCount: Int, + + @get:JsonProperty("lastRouteStart") + @param:JsonProperty("lastRouteStart") + val lastRouteStart: LocalDateTime? = null, + + @get:JsonProperty("lastRouteEnd") + @param:JsonProperty("lastRouteEnd") + val lastRouteEnd: LocalDateTime? = null, + + @get:JsonProperty("coveredAreaPercent") + @param:JsonProperty("coveredAreaPercent") + val coveredAreaPercent: Double? = null, + + @get:JsonProperty("durationMs") + @param:JsonProperty("durationMs") + val durationMs: Long +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ContourRequestDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ContourRequestDTO.kt new file mode 100644 index 0000000..9f429ea --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ContourRequestDTO.kt @@ -0,0 +1,37 @@ +package space.nstart.pcp.complan.types + + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(description = "Задание на расчет контура") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class ContourRequestDTO( + @get:JsonProperty("satelliteId") + @param:JsonProperty("satelliteId") + val satelliteId : Long, + + @get:JsonProperty("time") + @param:JsonProperty("time") + val time : LocalDateTime, + + @get:JsonProperty("gamma") + @param:JsonProperty("gamma") + val gamma : Double, + + @get:JsonProperty("duration") + @param:JsonProperty("duration") + val duration : Double, + + @get:JsonProperty("routeWidthKm") + @param:JsonProperty("routeWidthKm") + val routeWidthKm : Double, + + @get:JsonProperty("step") + @param:JsonProperty("step") + val step : Double = 1.0 +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanCalculationMode.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanCalculationMode.kt new file mode 100644 index 0000000..f4dc71f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanCalculationMode.kt @@ -0,0 +1,6 @@ +package space.nstart.pcp.complan.types + +enum class DynamicPlanCalculationMode { + FULL, + GREEDY +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanIntervalDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanIntervalDTO.kt new file mode 100644 index 0000000..9e1812f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanIntervalDTO.kt @@ -0,0 +1,43 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.util.UUID + +@Schema(description = "Интервал расчета динамического плана") +@JsonIgnoreProperties(ignoreUnknown = true) +data class DynamicPlanIntervalDTO( + @get:JsonProperty("id") + @param:JsonProperty("id") + val id: Long, + + @get:JsonProperty("runId") + @param:JsonProperty("runId") + val runId: UUID, + + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("intervalId") + @param:JsonProperty("intervalId") + val intervalId: UUID, + + @get:JsonProperty("regionId") + @param:JsonProperty("regionId") + val regionId: UUID, + + @get:JsonProperty("revSign") + @param:JsonProperty("revSign") + val revSign: RevolutionSign, + + @get:JsonProperty("contourWkt") + @param:JsonProperty("contourWkt") + val contourWkt: String, + + @get:JsonProperty("observationParametersCount") + @param:JsonProperty("observationParametersCount") + val observationParametersCount: Int +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRevolutionMode.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRevolutionMode.kt new file mode 100644 index 0000000..3eabc7d --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRevolutionMode.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.complan.types + +enum class DynamicPlanRevolutionMode { + BOTH, + ASC, + DESC +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRouteDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRouteDTO.kt new file mode 100644 index 0000000..a12e90d --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRouteDTO.kt @@ -0,0 +1,51 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +@Schema(description = "Маршрут расчета динамического плана") +@JsonIgnoreProperties(ignoreUnknown = true) +data class DynamicPlanRouteDTO( + @get:JsonProperty("id") + @param:JsonProperty("id") + val id: Long, + + @get:JsonProperty("runId") + @param:JsonProperty("runId") + val runId: UUID, + + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteId") + @param:JsonProperty("satelliteId") + val satelliteId: Long, + + @get:JsonProperty("startTime") + @param:JsonProperty("startTime") + val startTime: LocalDateTime, + + @get:JsonProperty("endTime") + @param:JsonProperty("endTime") + val endTime: LocalDateTime, + + @get:JsonProperty("duration") + @param:JsonProperty("duration") + val duration: Double, + + @get:JsonProperty("revolution") + @param:JsonProperty("revolution") + val revolution: Long, + + @get:JsonProperty("roll") + @param:JsonProperty("roll") + val roll: Double, + + @get:JsonProperty("contourWkt") + @param:JsonProperty("contourWkt") + val contourWkt: String +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRunDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRunDTO.kt new file mode 100644 index 0000000..eb7dc06 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/DynamicPlanRunDTO.kt @@ -0,0 +1,82 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +enum class DynamicPlanRunStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED +} + +@Schema(description = "Состояние запуска расчета динамического плана") +@JsonIgnoreProperties(ignoreUnknown = true) +data class DynamicPlanRunDTO( + @get:JsonProperty("runId") + @param:JsonProperty("runId") + val runId: UUID, + + @get:JsonProperty("status") + @param:JsonProperty("status") + val status: DynamicPlanRunStatus, + + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("calculationMode") + @param:JsonProperty("calculationMode") + val calculationMode: DynamicPlanCalculationMode, + + @get:JsonProperty("revolutionMode") + @param:JsonProperty("revolutionMode") + val revolutionMode: DynamicPlanRevolutionMode, + + @get:JsonProperty("filterCoveredRoutes") + @param:JsonProperty("filterCoveredRoutes") + val filterCoveredRoutes: Boolean, + + @get:JsonProperty("createdAt") + @param:JsonProperty("createdAt") + val createdAt: LocalDateTime, + + @get:JsonProperty("startedAt") + @param:JsonProperty("startedAt") + val startedAt: LocalDateTime?, + + @get:JsonProperty("finishedAt") + @param:JsonProperty("finishedAt") + val finishedAt: LocalDateTime?, + + @get:JsonProperty("durationMs") + @param:JsonProperty("durationMs") + val durationMs: Long?, + + @get:JsonProperty("resultStatus") + @param:JsonProperty("resultStatus") + val resultStatus: String?, + + @get:JsonProperty("routesCount") + @param:JsonProperty("routesCount") + val routesCount: Int?, + + @get:JsonProperty("errorMessage") + @param:JsonProperty("errorMessage") + val errorMessage: String? +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GeoJson.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GeoJson.kt new file mode 100644 index 0000000..19b4e19 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GeoJson.kt @@ -0,0 +1,136 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.databind.ObjectMapper +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.LineString +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.LinearRing +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.geom.Point +import java.io.File +import kotlin.collections.forEach +import kotlin.math.PI + + +class GeoJson { + + data class GeometryGeoJson(val type: String, val coordinates: Any) + data class Feature(val name: String, val bbox: List? = null, val type: String, val geometry: GeometryGeoJson, val properties: Any) + data class FeatureCollection(val type: String, val features: List) + + private fun multipolygonToGeojsonCoord(multipolygon: MultiPolygon, radToDeg : Boolean = false): List>>> { + return (0 until multipolygon.numGeometries).map{i-> + val polygon = multipolygon.getGeometryN(i) as Polygon + polygonToGeojsonCoord(polygon, radToDeg) + } + } + + private fun polygonToGeojsonCoord(polygon: Polygon, radToDeg : Boolean = false): List>> { + val coordinates = mutableListOf>>() + + coordinates.add(linearRingToCoord(polygon.exteriorRing, radToDeg)) + + (0 until polygon.numInteriorRing).forEach { i -> + coordinates.add(linearRingToCoord(polygon.getInteriorRingN(i), radToDeg)) + } + + return coordinates + } + + private fun linearRingToCoord(ring: LinearRing, radToDeg : Boolean = false): List> { + if (radToDeg) { + return ring.coordinates.map{ coordinate -> + listOf(coordinate.x * 180 / PI, coordinate.y * 180 / PI).toTypedArray() + } + } else { + return ring.coordinates.map{ coordinate -> + listOf(coordinate.x, coordinate.y).toTypedArray() + } + } + + } + + fun getBBox(geometry: Geometry): List{ + val bbox = geometry.envelopeInternal + return listOf( + bbox.minX, + bbox.maxY, + bbox.maxX, + bbox.minY, + ) + } + + /** + * Конвертация из Geometry в формат geoJSON + * @param geometry исходная геометрия + * @return Возвращает конвертированную геометрию для записи в формате geoGSON + */ + fun convertToGeoJson(geometry: Geometry): GeometryGeoJson { + return when (geometry) { + is Point -> GeometryGeoJson(type = "Point", coordinates = listOf( geometry.x, geometry.y )) + is LineString -> GeometryGeoJson(type = "LineString", coordinates = geometry.coordinates.map{arrayOf( it.x, it.y )}) + is Polygon -> GeometryGeoJson(type = "Polygon", coordinates = polygonToGeojsonCoord(geometry)) + is MultiPolygon -> GeometryGeoJson(type = "MultiPolygon", coordinates = multipolygonToGeojsonCoord(geometry)) + else -> throw IllegalArgumentException("Unknown geometry type: $geometry") + } + } + fun convertToGeoJsonRadToDeg(geometry: Geometry): GeometryGeoJson { + return when (geometry) { + is Point -> GeometryGeoJson(type = "Point", coordinates = listOf( radToDeg(geometry.x), (geometry.y) )) + is LineString -> GeometryGeoJson(type = "LineString", coordinates = geometry.coordinates.map{arrayOf( radToDeg(it.x), radToDeg(it.y) )}) + is Polygon -> GeometryGeoJson(type = "Polygon", coordinates = polygonToGeojsonCoord(geometry, radToDeg = true)) + is MultiPolygon -> GeometryGeoJson(type = "MultiPolygon", coordinates = multipolygonToGeojsonCoord(geometry, radToDeg = true)) + else -> throw IllegalArgumentException("Unknown geometry type: $geometry") + } + } + + fun radToDeg(value : Double): Double { + return value * 180 / PI + } + + companion object { + fun createFeatureCollection( + geoms: List, + fileName: String, + radToDeg: Boolean = false + ): FeatureCollection { + val gj = GeoJson() + val features = geoms.map { geom -> + Feature( + name = fileName, + bbox = gj.getBBox(geom.geometry), + type = "Feature", + geometry = if (radToDeg) { + gj.convertToGeoJsonRadToDeg(geom.geometry) + } else { + gj.convertToGeoJson(geom.geometry) + }, + properties = geom.properties + ) + } + return FeatureCollection(type = "FeatureCollection", features = features) + } + + /** + * Создать geoJSON. + * Файлы geoJson хранятся в папке ресурсов рядом с картой в папке geoJson. + * Названия **AprioryScans, Catalog, FactScans, Region** соответственно. + * @param geom массив элементов [GeoJsonItem], содержащий геометрию и свойства слоя при необходимости + * @param outputPath целевой путь сохранения файла + */ + fun createGeoJson(geoms: List, fileName: String, dirToSave: String, radToDeg: Boolean = false) { + val objectMapper = ObjectMapper() + val geoJsonString = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(createFeatureCollection(geoms, fileName, radToDeg)) + + val dataDir = File(dirToSave) + if(!dataDir.exists()){ + dataDir.mkdirs() + } + + val path = "$fileName.json" + val file = File(dataDir, path) + file.writeText(geoJsonString) + } + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GeoJsonItem.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GeoJsonItem.kt new file mode 100644 index 0000000..a34aa62 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GeoJsonItem.kt @@ -0,0 +1,210 @@ +package space.nstart.pcp.complan.types + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.LineString +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO + + +data class GeoJsonItem( + val geometry: Geometry, + val properties: Map, +){ + companion object { +// /** +// * Создание слоя интервалов с атрибутивной информацией +// * @param +// * @return +// */ +// fun createIntervalsLay(ka: KaPass): List { +// val rez = mutableListOf() +// for (view in ka.views) { +// var unionGeom: Geometry? = view.matPlan.interval.geometry//.contour[0].geometry +// +// if (unionGeom == null) continue +// val resItem = GeoJsonItem( +// geometry = unionGeom,//, //.geometryToDeg(), +// properties = mapOf( +// "Аппарат" to "${ka.ka.kaId}", +// "Время нач." to view.matPlan.tMinLDT.toString(),//.toDateString(), +// "Время кон." to view.matPlan.tMaxLDT.toString(),//.toDateString(), +// "Виток" to view.matPlan.vit, +// "Регион" to "${view.matPlan.interval.obj.reqRegionId}", +// ) +// ) +// rez.add(resItem) +// } +// +// return rez +// } + + /** + * Создание слоя интервалов с атрибутивной информацией + * @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией + */ + fun createIntervalsLay(interval: List): List { + val rez = mutableListOf() + for(interval in interval) { + + if(WKTReader().read(interval.geometry).getGeometryN(0).coordinates.first() != WKTReader().read(interval.geometry).getGeometryN(0).coordinates.last()) + continue + + val resItem = GeoJsonItem( + geometry = WKTReader().read(interval.geometry), + properties = mapOf( +// "№" to "${interval.num}", +// "Region" to "${interval.regionId}", +// "Interval" to "${interval.id}", + "mpl" to getMplStringForLay(interval.mpl) +// "mpl" to interval.mpl.map { "[kaid ${it.noradId}, rev ${it.revolutionBegin}, time ${it.timeBegin}]" }, +// "mpl" to "${interval.mpl}", + ) + ) + rez.add(resItem) + } + return rez + } + + /** + * Формирование атрибутивной информации по матрице планирования + * @return Возвращает строку с перечнем прохождений КА в минималистическом формате + * КА[[виток время][..]..] + */ + fun getMplStringForLay(mpl : List) : String { + val kaList = mutableSetOf()//.addAll(mpl.map { it.noradId }) + + mpl.sortedBy { it.timeBegin } + + mpl.forEach { + kaList.add(it.noradId) + } + + val res = StringBuilder() + + val kaListTmp = kaList.sortedBy { it } + + kaListTmp.forEach { kaId -> + val str = mpl.filter { it.noradId == kaId }.sortedBy { it.timeBegin }.map { "[rev ${it.revolutionBegin}, time ${it.timeBegin}, gamma ${(it.gammaMin + it.gammaMax)/2}, gammaMin ${it.gammaMin}, gammaMax ${it.gammaMax}]" } + + res.append("noradId:$kaId") + res.append("[") + str.forEach { res.append(it) } + res.append("]") + } + + return res.toString() + } + + /** + * Создание слоя маршрутов с атрибутивной информацией + * @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией + */ + fun createSurveyLay(surveys: List): List { + val rez = mutableListOf() + for(survey in surveys) { + + val geometry = WKTReader().read(survey.geometry) + + if (geometry !is Polygon) { continue } + + if(geometry.getGeometryN(0).coordinates.first() != geometry.getGeometryN(0).coordinates.last()) + continue + + val resItem = GeoJsonItem( + geometry = geometry, + properties = mapOf( + "noradId" to survey.mpl.noradId, + "rev" to survey.rev, + "gamma" to survey.gamma, + "time" to survey.time.toString(), + "duration" to survey.duration, +// "mpl" to survey.mpl.toString(), + ) + ) + rez.add(resItem) + } + return rez + } + + /** + * Создание слоя с атрибутивной информацией + * @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией + */ + fun createAnyLay(pols: List): MutableList { + val rez = mutableListOf() + for (pol in pols) { + val resItem = GeoJsonItem( + geometry = pol,//, + properties = mapOf() + ) + rez.add(resItem) + } + return rez + } + + /** + * Создание слоя с атрибутивной информацией + * @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией + */ + fun createRequestLay(requests: List): List { + val rez = mutableListOf() + for (item in requests) { + val resItem = GeoJsonItem( + geometry = WKTReader().read(item.geometry),//, + properties = mapOf( + "UUID" to "${item.id}", + "Name" to "${item.name}", + "Interval_begin" to "${item.intervalBegin}", + "Interval_end" to "${item.intervalEnd}", + "Importance" to "${item.importance}", + "Priority" to "${item.priority}", + "Resulution" to "${item.resolution}", + ) + ) + rez.add(resItem) + } + return rez + } + /** + * Создание слоя с атрибутивной информацией + * @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией + */ + fun createAplCatalogLay(aplCatalog: List): List { + val rez = mutableListOf() + for (item in aplCatalog) { + val resItem = GeoJsonItem( + geometry = item.geometry, + properties = mapOf( + "UUID" to "${item.id}", + "Name" to "${item.name}", +// "Interval_begin" to "${item.intervalBegin}", +// "Interval_end" to "${item.intervalEnd}", +// "Importance" to "${item.importance}", +// "Priority" to "${item.priority}", +// "Resulution" to "${item.resolution}", + ) + ) + rez.add(resItem) + } + return rez + } + + /** + * Создание слой для отображения из LineString + * @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией + */ + fun createLineStringLay(lines: List): MutableList { + val rez = mutableListOf() + for (item in lines) { + val resItem = GeoJsonItem( + geometry = item,//, + properties = mapOf() + ) + rez.add(resItem) + } + return rez + } + + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GreedyMathDebugDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GreedyMathDebugDTO.kt new file mode 100644 index 0000000..f88536f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/GreedyMathDebugDTO.kt @@ -0,0 +1,163 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID + +@Schema(description = "Debug-запрос расчета greedy-математики по сохраненным параметрам наблюдения") +data class GreedyMathDebugRequestDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("revolutionMode") + @param:JsonProperty("revolutionMode") + val revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH, + + @get:JsonProperty("filterCoveredRoutes") + @param:JsonProperty("filterCoveredRoutes") + val filterCoveredRoutes: Boolean = false +) + +@Schema(description = "Выбранное greedy-включение без геометрии маршрута") +@JsonIgnoreProperties(ignoreUnknown = true) +data class GreedyMathCandidateDTO( + @get:JsonProperty("intervalId") + @param:JsonProperty("intervalId") + val intervalId: UUID, + + @get:JsonProperty("regionId") + @param:JsonProperty("regionId") + val regionId: UUID, + + @get:JsonProperty("satelliteId") + @param:JsonProperty("satelliteId") + val satelliteId: Long, + + @get:JsonProperty("revolution") + @param:JsonProperty("revolution") + val revolution: Long, + + @get:JsonProperty("day") + @param:JsonProperty("day") + val day: LocalDate, + + @get:JsonProperty("timeBegin") + @param:JsonProperty("timeBegin") + val timeBegin: LocalDateTime, + + @get:JsonProperty("timeEnd") + @param:JsonProperty("timeEnd") + val timeEnd: LocalDateTime, + + @get:JsonProperty("durationSeconds") + @param:JsonProperty("durationSeconds") + val durationSeconds: Long, + + @get:JsonProperty("gamma") + @param:JsonProperty("gamma") + val gamma: Double +) + +@Schema(description = "Debug-результат greedy-математики по сохраненным параметрам наблюдения") +@JsonIgnoreProperties(ignoreUnknown = true) +data class GreedyMathDebugResultDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("missingSatelliteIds") + @param:JsonProperty("missingSatelliteIds") + val missingSatelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("status") + @param:JsonProperty("status") + val status: ComplexPlanCalculationStatus, + + @get:JsonProperty("revolutionMode") + @param:JsonProperty("revolutionMode") + val revolutionMode: DynamicPlanRevolutionMode, + + @get:JsonProperty("observationParametersFilePath") + @param:JsonProperty("observationParametersFilePath") + val observationParametersFilePath: String? = null, + + @get:JsonProperty("intervalsCount") + @param:JsonProperty("intervalsCount") + val intervalsCount: Int, + + @get:JsonProperty("observationParametersCount") + @param:JsonProperty("observationParametersCount") + val observationParametersCount: Int, + + @get:JsonProperty("selectedCandidatesCount") + @param:JsonProperty("selectedCandidatesCount") + val selectedCandidatesCount: Int, + + @get:JsonProperty("failuresCount") + @param:JsonProperty("failuresCount") + val failuresCount: Int, + + @get:JsonProperty("selectedCandidates") + @param:JsonProperty("selectedCandidates") + val selectedCandidates: List, + + @get:JsonProperty("filterCoveredRoutes") + @param:JsonProperty("filterCoveredRoutes") + val filterCoveredRoutes: Boolean, + + @get:JsonProperty("routesBeforeUsefulAreaFilterCount") + @param:JsonProperty("routesBeforeUsefulAreaFilterCount") + val routesBeforeUsefulAreaFilterCount: Int, + + @get:JsonProperty("routesAfterUsefulAreaFilterCount") + @param:JsonProperty("routesAfterUsefulAreaFilterCount") + val routesAfterUsefulAreaFilterCount: Int, + + @get:JsonProperty("routeBuildFailuresCount") + @param:JsonProperty("routeBuildFailuresCount") + val routeBuildFailuresCount: Int, + + @get:JsonProperty("greedySelectionMs") + @param:JsonProperty("greedySelectionMs") + val greedySelectionMs: Long, + + @get:JsonProperty("routeBuildMs") + @param:JsonProperty("routeBuildMs") + val routeBuildMs: Long, + + @get:JsonProperty("usefulAreaAnalysisMs") + @param:JsonProperty("usefulAreaAnalysisMs") + val usefulAreaAnalysisMs: Long, + + @get:JsonProperty("totalMs") + @param:JsonProperty("totalMs") + val totalMs: Long +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/IntervalItemDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/IntervalItemDTO.kt new file mode 100644 index 0000000..8eebc4a --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/IntervalItemDTO.kt @@ -0,0 +1,34 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.util.* + +@Schema(description = "Интервал") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class IntervalItemDTO( + @get:JsonProperty("id") + @param:JsonProperty("id") + val id : UUID = UUID.randomUUID(), + + @get:JsonProperty("regionId") + @param:JsonProperty("regionId") + val regionId : UUID, + + @get:JsonProperty("revSign") + @param:JsonProperty("revSign") + val revSign : RevolutionSign, + + @get:JsonProperty("geometry") + @param:JsonProperty("geometry") + val geometry: String, + + @get:JsonProperty("mpl") + @param:JsonProperty("mpl") + val mpl: MutableList = mutableListOf() +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObjDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObjDTO.kt new file mode 100644 index 0000000..abe0948 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObjDTO.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import java.util.UUID + +@Schema(description = "Модель данных объекта для расчета наблюдения") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class ObjDTO( + @get:JsonProperty("id") + @param:JsonProperty("id") + val id: UUID, + + @get:JsonProperty("position") + @param:JsonProperty("position") + val position: PositionDTO? = null, + + @get:JsonProperty("contourWKT") + @param:JsonProperty("contourWKT") + val contourWKT: String? = null +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObjViewRequestDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObjViewRequestDTO.kt new file mode 100644 index 0000000..da6857c --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObjViewRequestDTO.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(description = "Запрос расчета параметров наблюдения для набора объектов") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class ObjViewRequestDTO( + @get:JsonProperty("sunAngleMin") + @param:JsonProperty("sunAngleMin") + val sunAngleMin: Double? = null, + + @get:JsonProperty("objects") + @param:JsonProperty("objects") + val objects: List, + + @get:JsonProperty("satellites") + @param:JsonProperty("satellites") + val satellites: Iterable? = null, + + @get:JsonProperty("timeStart") + @param:JsonProperty("timeStart") + val timeStart: LocalDateTime? = null, + + @get:JsonProperty("timeStop") + @param:JsonProperty("timeStop") + val timeStop: LocalDateTime? = null +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt new file mode 100644 index 0000000..6db84b0 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt @@ -0,0 +1,164 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.Instant +import java.time.LocalDateTime +import java.util.UUID + +@Schema(description = "Debug-запрос построения интервалов и расчета параметров наблюдения") +@JsonIgnoreProperties(ignoreUnknown = true) +data class ObservationParametersDebugRequestDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("parallelBallisticsCalculation") + @param:JsonProperty("parallelBallisticsCalculation") + val parallelBallisticsCalculation: Boolean = false, + + @get:JsonProperty("chunkSize") + @param:JsonProperty("chunkSize") + val chunkSize: Int? = null, + + @get:JsonProperty("parallelism") + @param:JsonProperty("parallelism") + val parallelism: Int? = null, + + @get:JsonProperty("saveResultToFile") + @param:JsonProperty("saveResultToFile") + val saveResultToFile: Boolean = false +) + +@Schema(description = "Debug-результат построения интервалов и расчета параметров наблюдения") +@JsonIgnoreProperties(ignoreUnknown = true) +data class ObservationParametersDebugResultDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("missingSatelliteIds") + @param:JsonProperty("missingSatelliteIds") + val missingSatelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("status") + @param:JsonProperty("status") + val status: ComplexPlanCalculationStatus, + + @get:JsonProperty("parallelBallisticsCalculation") + @param:JsonProperty("parallelBallisticsCalculation") + val parallelBallisticsCalculation: Boolean, + + @get:JsonProperty("intervalsCount") + @param:JsonProperty("intervalsCount") + val intervalsCount: Int, + + @get:JsonProperty("observationParametersCount") + @param:JsonProperty("observationParametersCount") + val observationParametersCount: Int, + + @get:JsonProperty("chunksCount") + @param:JsonProperty("chunksCount") + val chunksCount: Int, + + @get:JsonProperty("intervalsMs") + @param:JsonProperty("intervalsMs") + val intervalsMs: Long, + + @get:JsonProperty("observationParametersMs") + @param:JsonProperty("observationParametersMs") + val observationParametersMs: Long, + + @get:JsonProperty("totalMs") + @param:JsonProperty("totalMs") + val totalMs: Long, + + @get:JsonProperty("savedToFile") + @param:JsonProperty("savedToFile") + val savedToFile: Boolean = false, + + @get:JsonProperty("resultFilePath") + @param:JsonProperty("resultFilePath") + val resultFilePath: String? = null +) + +@Schema(description = "Файловый артефакт debug-расчета интервалов и параметров наблюдения") +@JsonIgnoreProperties(ignoreUnknown = true) +data class ObservationParametersDebugFileDTO( + @get:JsonProperty("schemaVersion") + @param:JsonProperty("schemaVersion") + val schemaVersion: Int = 1, + + @get:JsonProperty("createdAt") + @param:JsonProperty("createdAt") + val createdAt: Instant, + + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("missingSatelliteIds") + @param:JsonProperty("missingSatelliteIds") + val missingSatelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("parallelBallisticsCalculation") + @param:JsonProperty("parallelBallisticsCalculation") + val parallelBallisticsCalculation: Boolean, + + @get:JsonProperty("chunkSize") + @param:JsonProperty("chunkSize") + val chunkSize: Int?, + + @get:JsonProperty("parallelism") + @param:JsonProperty("parallelism") + val parallelism: Int?, + + @get:JsonProperty("intervalsCount") + @param:JsonProperty("intervalsCount") + val intervalsCount: Int, + + @get:JsonProperty("observationParametersCount") + @param:JsonProperty("observationParametersCount") + val observationParametersCount: Int, + + @get:JsonProperty("intervals") + @param:JsonProperty("intervals") + val intervals: List +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/RequestItemDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/RequestItemDTO.kt new file mode 100644 index 0000000..05214ab --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/RequestItemDTO.kt @@ -0,0 +1,73 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +@Schema(description = "Заявка для расчета комплексного плана") +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonSerialize +data class RequestItemDTO( + @get:JsonProperty("id") + @param:JsonProperty("id") + val id : UUID = UUID.randomUUID(), + + @get:JsonProperty("name") + @param:JsonProperty("name") + val name : String, + + @get:JsonProperty("originalGeometry") + @param:JsonProperty("originalGeometry") + @param:JsonAlias("geometry", "remainingGeometry") + val geometry : String, + + @get:JsonProperty("intervalBegin") + @param:JsonProperty("intervalBegin") + val intervalBegin : LocalDateTime, + + @get:JsonProperty("intervalEnd") + @param:JsonProperty("intervalEnd") + val intervalEnd : LocalDateTime, + + @get:JsonProperty("resolution") + @param:JsonProperty("resolution") + val resolution : Double, + + @get:JsonProperty("priority") + @param:JsonProperty("priority") + val priority : Int? = null, + + @get:JsonProperty("importance") + @param:JsonProperty("importance") + val importance : Double, + + /**Исходная площадь района. + * Используется для расчета важности задания */ + @get:JsonProperty("baseArea") + @param:JsonProperty("baseArea") + var baseArea : Double? = null, + + /**Текущая площадь района. + * Используется для расчета важности задания */ + @get:JsonProperty("currentArea") + @param:JsonProperty("currentArea") + var currentArea : Double? = null, + + /**Дата время последнего наблюдения. + * Используется для расчета важности задания */ + @get:JsonProperty("dateTimeLastObservation") + @param:JsonProperty("dateTimeLastObservation") + var dateTimeLastObservation : LocalDateTime? = null, + + @get:JsonProperty("appType") + @param:JsonProperty("appType") + val appType : Int = 0, + + @get:JsonProperty("intervals") + @param:JsonProperty("intervals") + val intervals : MutableList? = null, +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/SurveyContour.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/SurveyContour.kt new file mode 100644 index 0000000..39e5df8 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/SurveyContour.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.complan.types + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.LocalDateTime + +data class SurveyContour( + val mpl : SquareViewParamDTO, + val time : LocalDateTime, + val duration : Double, + val rev : Long, + val gamma : Double, + val geometry : String, +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/TestCalcMarsDebugDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/TestCalcMarsDebugDTO.kt new file mode 100644 index 0000000..1471d4a --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/TestCalcMarsDebugDTO.kt @@ -0,0 +1,86 @@ +package space.nstart.pcp.complan.types + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime +import java.util.UUID + +@Schema(description = "Debug-запрос построения маршрутов по всем параметрам видимости из файла") +data class TestCalcMarsDebugRequestDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime +) + +@Schema(description = "Debug-результат построения маршрутов по всем параметрам видимости из файла") +@JsonIgnoreProperties(ignoreUnknown = true) +data class TestCalcMarsDebugResultDTO( + @get:JsonProperty("requestId") + @param:JsonProperty("requestId") + val requestId: UUID, + + @get:JsonProperty("satelliteIds") + @param:JsonProperty("satelliteIds") + val satelliteIds: List, + + @get:JsonProperty("missingSatelliteIds") + @param:JsonProperty("missingSatelliteIds") + val missingSatelliteIds: List, + + @get:JsonProperty("calculationStart") + @param:JsonProperty("calculationStart") + val calculationStart: LocalDateTime, + + @get:JsonProperty("calculationEnd") + @param:JsonProperty("calculationEnd") + val calculationEnd: LocalDateTime, + + @get:JsonProperty("status") + @param:JsonProperty("status") + val status: ComplexPlanCalculationStatus, + + @get:JsonProperty("observationParametersFilePath") + @param:JsonProperty("observationParametersFilePath") + val observationParametersFilePath: String? = null, + + @get:JsonProperty("intervalsCount") + @param:JsonProperty("intervalsCount") + val intervalsCount: Int, + + @get:JsonProperty("observationParametersCount") + @param:JsonProperty("observationParametersCount") + val observationParametersCount: Int, + + @get:JsonProperty("routeBuildAttemptsCount") + @param:JsonProperty("routeBuildAttemptsCount") + val routeBuildAttemptsCount: Int, + + @get:JsonProperty("routesCount") + @param:JsonProperty("routesCount") + val routesCount: Int, + + @get:JsonProperty("routeBuildFailuresCount") + @param:JsonProperty("routeBuildFailuresCount") + val routeBuildFailuresCount: Int, + + @get:JsonProperty("routeBuildMs") + @param:JsonProperty("routeBuildMs") + val routeBuildMs: Long, + + @get:JsonProperty("totalMs") + @param:JsonProperty("totalMs") + val totalMs: Long +) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/utils/extensions/GeometryExtensions.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/utils/extensions/GeometryExtensions.kt new file mode 100644 index 0000000..0fdb497 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/utils/extensions/GeometryExtensions.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.complan.utils.extensions + +import org.locationtech.jts.geom.CoordinateFilter +import org.locationtech.jts.geom.Geometry +import kotlin.math.PI + + +fun Geometry.radiansToDegrees(): Geometry { + // Создаём копию, чтобы не мутировать оригинал + val copy = this.copy() as Geometry + + // Применяем фильтр, который конвертирует радианы → градусы + copy.apply(CoordinateFilter { coord -> + coord.x = coord.x * 180.0 / PI // долгота + coord.y = coord.y * 180.0 / PI // широта + }) + + return copy +} \ No newline at end of file diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/utils/extensions/RevolutionSignExtension.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/utils/extensions/RevolutionSignExtension.kt new file mode 100644 index 0000000..eaae9fa --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/utils/extensions/RevolutionSignExtension.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.complan.utils.extensions + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign + +fun RevolutionSign.toInt() : Int { + return when (this) { + RevolutionSign.ASC -> 0 + RevolutionSign.DESC -> 1 + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml new file mode 100644 index 0000000..9c2430b --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml @@ -0,0 +1,78 @@ +spring: + application: + name: pcp-dynamic-plan-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888} + fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} + profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} + label: ${SPRING_CLOUD_CONFIG_LABEL:master} +# codec: +# max-in-memory-size: 16MB + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://192.168.60.68:5432/complan + username: postgres + password: password + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: false + jdbc: + batch_size: 40 + order_inserts: true + order_updates: true + + feign: + client: + config: + default: + connectTimeout: 5000 + readTimeout: 30000 + loggerLevel: basic + + flyway: + enabled: true + locations: classpath:db/migration/pcp-complex-plan + baseline-on-migrate: true + ignore-migration-patterns: "*:missing" + baseline-version: 0 + validate-on-migrate: true + repair-on-migrate: true + clean-disabled: true + out-of-order: true + schemas: public + +logging: + level: + org.flywaydb: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.springframework.jdbc.core: TRACE + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /v3/api-docs + +complex-plan: + observation-parameters: + parallel-enabled: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_PARALLEL_ENABLED:true} + chunk-size: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_CHUNK_SIZE:20} + parallelism: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_PARALLELISM:8} + debug-output-dir: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_DEBUG_OUTPUT_DIR:${java.io.tmpdir}/pcp-dynamic-plan-service/obsParams} + geo-layers: + enabled: false + output-dir: ${java.io.tmpdir}/pcp-dynamic-plan-service/geo-layers diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/V1__initional.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/V1__initional.sql new file mode 100644 index 0000000..b0f41fc --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/V1__initional.sql @@ -0,0 +1 @@ +SELECT 'Database initialization complete' as status; \ No newline at end of file diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/V2__aplSettings.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/V2__aplSettings.sql new file mode 100644 index 0000000..2e42f1e --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/V2__aplSettings.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS aplsettings ( + id SERIAL PRIMARY KEY, --серийный ключ + settings TEXT --настройки автомата в виде JSON + ); \ No newline at end of file diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V2__aplSettings.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V2__aplSettings.sql new file mode 100644 index 0000000..b501e99 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V2__aplSettings.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS aplsettings ( + id SERIAL PRIMARY KEY, --серийный ключ + settings TEXT --настройки автомата в виде JSON + ); diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V3__dynamic_plan_run.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V3__dynamic_plan_run.sql new file mode 100644 index 0000000..b565f8c --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V3__dynamic_plan_run.sql @@ -0,0 +1,41 @@ +create table if not exists dynamic_plan_run +( + id uuid primary key, + status varchar(32) not null, + request_id uuid not null, + satellite_ids text not null, + calculation_start timestamp not null, + calculation_end timestamp not null, + created_at timestamp not null, + started_at timestamp, + finished_at timestamp, + duration_ms bigint, + result_status varchar(64), + routes_count integer, + result_json text, + error_message text +); + +create index if not exists idx_dynamic_plan_run_status on dynamic_plan_run(status); +create index if not exists idx_dynamic_plan_run_request_id on dynamic_plan_run(request_id); + +create table if not exists dynamic_plan_route +( + id bigserial primary key, + run_id uuid not null references dynamic_plan_run(id) on delete cascade, + request_id uuid not null, + satellite_id bigint not null, + start_time timestamp not null, + end_time timestamp not null, + duration double precision not null, + revolution bigint not null, + roll double precision not null, + contour_wkt text not null, + route_order integer not null +); + +create index if not exists idx_dynamic_plan_route_run_id on dynamic_plan_route(run_id); +create index if not exists idx_dynamic_plan_route_request_id on dynamic_plan_route(request_id); +create index if not exists idx_dynamic_plan_route_satellite_id on dynamic_plan_route(satellite_id); +create index if not exists idx_dynamic_plan_route_start_time on dynamic_plan_route(start_time); +create index if not exists idx_dynamic_plan_route_run_start on dynamic_plan_route(run_id, start_time); diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V4__dynamic_plan_calculation_mode.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V4__dynamic_plan_calculation_mode.sql new file mode 100644 index 0000000..c2aa053 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V4__dynamic_plan_calculation_mode.sql @@ -0,0 +1,2 @@ +alter table dynamic_plan_run + add column if not exists calculation_mode varchar(32) not null default 'FULL'; diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V5__dynamic_plan_revolution_mode.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V5__dynamic_plan_revolution_mode.sql new file mode 100644 index 0000000..27012ca --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V5__dynamic_plan_revolution_mode.sql @@ -0,0 +1,2 @@ +alter table dynamic_plan_run + add column if not exists revolution_mode varchar(32) not null default 'BOTH'; diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V6__dynamic_plan_filter_covered_routes.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V6__dynamic_plan_filter_covered_routes.sql new file mode 100644 index 0000000..a45efdb --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V6__dynamic_plan_filter_covered_routes.sql @@ -0,0 +1,2 @@ +alter table dynamic_plan_run + add column if not exists filter_covered_routes boolean not null default false; diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V7__dynamic_plan_min_useful_route_area.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V7__dynamic_plan_min_useful_route_area.sql new file mode 100644 index 0000000..3e40dd4 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V7__dynamic_plan_min_useful_route_area.sql @@ -0,0 +1,2 @@ +alter table dynamic_plan_run + add column if not exists min_useful_route_area_km2 double precision not null default 0.0; diff --git a/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V8__dynamic_plan_interval.sql b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V8__dynamic_plan_interval.sql new file mode 100644 index 0000000..1ca8519 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/main/resources/db/migration/pcp-complex-plan/V8__dynamic_plan_interval.sql @@ -0,0 +1,17 @@ +create table if not exists dynamic_plan_interval +( + id bigserial primary key, + run_id uuid not null references dynamic_plan_run(id) on delete cascade, + request_id uuid not null, + interval_id uuid not null, + region_id uuid not null, + rev_sign varchar(16) not null, + contour_wkt text not null, + observation_parameters_count integer not null, + interval_order integer not null +); + +create index if not exists idx_dynamic_plan_interval_run_id on dynamic_plan_interval(run_id); +create index if not exists idx_dynamic_plan_interval_request_id on dynamic_plan_interval(request_id); +create index if not exists idx_dynamic_plan_interval_region_id on dynamic_plan_interval(region_id); +create index if not exists idx_dynamic_plan_interval_run_order on dynamic_plan_interval(run_id, interval_order); diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/ComPlanControllerTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/ComPlanControllerTest.kt new file mode 100644 index 0000000..0ee05ff --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/ComPlanControllerTest.kt @@ -0,0 +1,132 @@ +package space.nstart.pcp.complan + +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ComPlanControllerTest { + + @Test + fun `exposes calculation run endpoints`() { + val mappedMethods = ComPlanController::class.java.declaredMethods + .filter { method -> + listOf( + GetMapping::class.java, + PostMapping::class.java, + PutMapping::class.java, + PatchMapping::class.java, + DeleteMapping::class.java, + RequestMapping::class.java + ).any { annotation -> method.isAnnotationPresent(annotation) } + } + .map { it.name } + .sorted() + + assertEquals( + listOf( + "calcPlan", + "debugGreedyMath", + "debugObservationParameters", + "getCalculation", + "getCalculationIntervals", + "getCalculationResult", + "getCalculationRoutes", + "getCalculations", + "testCalcMars" + ), + mappedMethods + ) + } + + @Test + fun `calcPlan is exposed as POST endpoint`() { + val calcPlanMethod = ComPlanController::class.java.getDeclaredMethod( + "calcPlan", + space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO::class.java + ) + + assertNotNull(calcPlanMethod.getAnnotation(PostMapping::class.java)) + assertNull(calcPlanMethod.getAnnotation(GetMapping::class.java)) + } + + @Test + fun `observation parameters debug endpoint is exposed as POST endpoint`() { + val debugMethod = ComPlanController::class.java.getDeclaredMethod( + "debugObservationParameters", + space.nstart.pcp.complan.types.ObservationParametersDebugRequestDTO::class.java + ) + + assertNotNull(debugMethod.getAnnotation(PostMapping::class.java)) + assertNull(debugMethod.getAnnotation(GetMapping::class.java)) + } + + @Test + fun `greedy math debug endpoint is exposed as POST endpoint`() { + val debugMethod = ComPlanController::class.java.getDeclaredMethod( + "debugGreedyMath", + space.nstart.pcp.complan.types.GreedyMathDebugRequestDTO::class.java + ) + + assertNotNull(debugMethod.getAnnotation(PostMapping::class.java)) + assertNull(debugMethod.getAnnotation(GetMapping::class.java)) + } + + @Test + fun `test calc mars debug endpoint is exposed as POST endpoint`() { + val debugMethod = ComPlanController::class.java.getDeclaredMethod( + "testCalcMars", + space.nstart.pcp.complan.types.TestCalcMarsDebugRequestDTO::class.java + ) + + assertNotNull(debugMethod.getAnnotation(PostMapping::class.java)) + assertNull(debugMethod.getAnnotation(GetMapping::class.java)) + } + + @Test + fun `calculation status and result are exposed as GET endpoints`() { + val statusMethod = ComPlanController::class.java.getDeclaredMethod( + "getCalculation", + java.util.UUID::class.java + ) + val resultMethod = ComPlanController::class.java.getDeclaredMethod( + "getCalculationResult", + java.util.UUID::class.java + ) + val routesMethod = ComPlanController::class.java.getDeclaredMethod( + "getCalculationRoutes", + java.util.UUID::class.java, + Int::class.java, + Int::class.java + ) + val intervalsMethod = ComPlanController::class.java.getDeclaredMethod( + "getCalculationIntervals", + java.util.UUID::class.java, + Int::class.java, + Int::class.java + ) + val runsMethod = ComPlanController::class.java.getDeclaredMethod( + "getCalculations", + space.nstart.pcp.complan.types.DynamicPlanRunStatus::class.java, + Int::class.java, + Int::class.java + ) + + assertNotNull(statusMethod.getAnnotation(GetMapping::class.java)) + assertNotNull(resultMethod.getAnnotation(GetMapping::class.java)) + assertNotNull(routesMethod.getAnnotation(GetMapping::class.java)) + assertNotNull(intervalsMethod.getAnnotation(GetMapping::class.java)) + assertNotNull(runsMethod.getAnnotation(GetMapping::class.java)) + assertNull(statusMethod.getAnnotation(PostMapping::class.java)) + assertNull(resultMethod.getAnnotation(PostMapping::class.java)) + assertNull(routesMethod.getAnnotation(PostMapping::class.java)) + assertNull(intervalsMethod.getAnnotation(PostMapping::class.java)) + assertNull(runsMethod.getAnnotation(PostMapping::class.java)) + } +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculatorTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculatorTest.kt new file mode 100644 index 0000000..6dcb8d4 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculatorTest.kt @@ -0,0 +1,457 @@ +package space.nstart.pcp.complan.apl + +import org.locationtech.jts.io.WKTReader +import space.nstart.pcp.complan.settings.SettingsDTO +import space.nstart.pcp.complan.spacecraft.SCConstraints +import space.nstart.pcp.complan.spacecraft.SpaceCraft +import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode +import space.nstart.pcp.complan.types.IntervalItemDTO +import space.nstart.pcp.complan.types.SurveyContour +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +class BasicPlanCalculatorTest { + + @Test + fun `selects only satellites present in catalog`() { + val result = selectSatellitesForCalculation( + requestedSatelliteIds = listOf(56756L, 62138L, 99999L), + catalogSatelliteIds = listOf(56756L, 62138L) + ) + + assertEquals(listOf(56756L, 62138L), result.existingIds) + assertEquals(listOf(99999L), result.missingIds) + } + + @Test + fun `keeps requested order and removes duplicates`() { + val result = selectSatellitesForCalculation( + requestedSatelliteIds = listOf(62138L, 56756L, 62138L), + catalogSatelliteIds = listOf(56756L, 62138L) + ) + + assertEquals(listOf(62138L, 56756L), result.existingIds) + assertEquals(emptyList(), result.missingIds) + } + + @Test + fun `returns all requested satellites as missing when catalog has none of them`() { + val result = selectSatellitesForCalculation( + requestedSatelliteIds = listOf(1L, 2L, 3L), + catalogSatelliteIds = listOf(56756L, 62138L) + ) + + assertEquals(emptyList(), result.existingIds) + assertEquals(listOf(1L, 2L, 3L), result.missingIds) + } + + @Test + fun `route build uses all available MPL items sorted by gamma`() { + val first = mplItem(noradId = 1L, gammaMin = -12.0) + val second = mplItem(noradId = 2L, gammaMin = 4.0) + val third = mplItem(noradId = 3L, gammaMin = -8.0) + + val result = selectMplItemsForRouteBuild( + mplItems = listOf(first, second, third), + settings = SettingsDTO() + ) + + assertEquals(listOf(second, third, first), result) + } + + @Test + fun `route build keeps existing gamma and revolution filters`() { + val ascInGamma = mplItem(noradId = 1L, gammaMin = 3.0, gammaMax = 5.0, revSignBegin = RevolutionSign.ASC) + val descInGamma = mplItem(noradId = 2L, gammaMin = 2.0, gammaMax = 4.0, revSignBegin = RevolutionSign.DESC) + val ascOutOfGamma = mplItem(noradId = 3L, gammaMin = 20.0, gammaMax = 24.0, revSignBegin = RevolutionSign.ASC) + + val result = selectMplItemsForRouteBuild( + mplItems = listOf(ascOutOfGamma, descInGamma, ascInGamma), + settings = SettingsDTO( + filterByGamma = true, + gammaValue = 10.0, + filterByRevolution = true, + revolutionType = 0 + ) + ) + + assertEquals(listOf(ascInGamma), result) + } + + @Test + fun `greedy route selection builds one route per interval`() { + val interval = interval( + mplItem(noradId = 1L, gammaMin = 2.0), + mplItem(noradId = 2L, gammaMin = 1.0) + ) + + val result = selectGreedyRouteCandidates( + intervals = listOf(interval), + settings = SettingsDTO() + ) + + assertEquals(1, result.selectedCandidates.size) + assertEquals(interval.id, result.selectedCandidates.single().intervalId) + assertEquals(2L, result.selectedCandidates.single().satelliteId) + assertEquals(emptyList(), result.failures) + } + + @Test + fun `greedy route selection prioritizes lower roll angle over earlier time`() { + val earlierHighRollInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 12.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 1) + ) + ) + val laterLowRollInterval = interval( + mplItem( + noradId = 2L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 10), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 11) + ) + ) + + val result = selectGreedyRouteCandidates( + intervals = listOf(earlierHighRollInterval, laterLowRollInterval), + settings = SettingsDTO() + ) + + assertEquals( + listOf(laterLowRollInterval.id, earlierHighRollInterval.id), + result.selectedCandidates.map { it.intervalId } + ) + assertEquals(emptyList(), result.failures) + } + + @Test + fun `greedy route selection filters intervals by revolution mode`() { + val ascInterval = interval( + mplItem(noradId = 1L, gammaMin = 1.0, revSignBegin = RevolutionSign.ASC), + revSign = RevolutionSign.ASC + ) + val descInterval = interval( + mplItem(noradId = 2L, gammaMin = 2.0, revSignBegin = RevolutionSign.DESC), + revSign = RevolutionSign.DESC + ) + + val result = selectGreedyRouteCandidates( + intervals = listOf(ascInterval, descInterval), + settings = SettingsDTO(), + revolutionMode = DynamicPlanRevolutionMode.DESC + ) + + assertEquals(listOf(descInterval.id), result.selectedCandidates.map { it.intervalId }) + assertEquals(emptyList(), result.failures) + } + + @Test + fun `interval build uses only requested revolution signs`() { + assertEquals(listOf(RevolutionSign.ASC), revolutionSignsForIntervals(DynamicPlanRevolutionMode.ASC)) + assertEquals(listOf(RevolutionSign.DESC), revolutionSignsForIntervals(DynamicPlanRevolutionMode.DESC)) + assertEquals( + listOf(RevolutionSign.DESC, RevolutionSign.ASC), + revolutionSignsForIntervals(DynamicPlanRevolutionMode.BOTH) + ) + } + + @Test + fun `greedy route selection avoids simultaneous routes for same satellite`() { + val firstInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 1) + ) + ) + val secondInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 1, 30), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 2) + ), + mplItem( + noradId = 2L, + gammaMin = 2.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 1, 30), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 2) + ) + ) + + val result = selectGreedyRouteCandidates( + intervals = listOf(secondInterval, firstInterval), + settings = SettingsDTO() + ) + + assertEquals(listOf(1L, 2L), result.selectedCandidates.map { it.satelliteId }) + assertEquals(emptyList(), result.failures) + } + + @Test + fun `greedy route selection reports revolution limit failures`() { + val firstInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 1.0, + revolutionBegin = 10, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 4, 10) + ) + ) + val secondInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 1.0, + revolutionBegin = 10, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 6), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 8) + ) + ) + + val result = selectGreedyRouteCandidates( + intervals = listOf(firstInterval, secondInterval), + settings = SettingsDTO() + ) + + assertEquals(1, result.selectedCandidates.size) + assertEquals(GreedyIntervalFailureReason.REVOLUTION_LIMIT_EXCEEDED, result.failures.single().reason) + } + + @Test + fun `greedy route selection reports daily limit failures`() { + val firstInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 1.0, + revolutionBegin = 10, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 4, 50) + ) + ) + val secondInterval = interval( + mplItem( + noradId = 1L, + gammaMin = 1.0, + revolutionBegin = 11, + timeBegin = LocalDateTime.of(2026, 5, 1, 0, 6), + timeEnd = LocalDateTime.of(2026, 5, 1, 0, 8) + ) + ) + + val result = selectGreedyRouteCandidates( + intervals = listOf(firstInterval, secondInterval), + settings = SettingsDTO(), + spacecraft = listOf( + SpaceCraft( + id = 1L, + constraints = SCConstraints(maxDailyDurationSeconds = 300) + ) + ) + ) + + assertEquals(1, result.selectedCandidates.size) + assertEquals(GreedyIntervalFailureReason.DAILY_LIMIT_EXCEEDED, result.failures.single().reason) + } + + @Test + fun `remaining request geometry filter keeps route when it still covers uncovered request territory`() { + val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))") + val earlierRoute = route( + noradId = 1L, + time = LocalDateTime.of(2026, 5, 1, 0, 0), + geometry = "POLYGON ((-1 0, -1 1, 0.5 1, 0.5 0, -1 0))" + ) + val laterRoute = route( + noradId = 2L, + time = LocalDateTime.of(2026, 5, 1, 0, 10), + geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))" + ) + + val result = filterRoutesByRemainingRequestGeometry(listOf(earlierRoute, laterRoute), requestGeometry) + + assertEquals(listOf(earlierRoute, laterRoute), result) + } + + @Test + fun `remaining request geometry filter removes route after earlier route covers request territory`() { + val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))") + val earlierRoute = route( + noradId = 1L, + time = LocalDateTime.of(2026, 5, 1, 0, 0), + geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))" + ) + val laterRoute = route( + noradId = 2L, + time = LocalDateTime.of(2026, 5, 1, 0, 10), + geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))" + ) + + val result = filterRoutesByRemainingRequestGeometry(listOf(earlierRoute, laterRoute), requestGeometry) + + assertEquals(listOf(earlierRoute), result) + } + + @Test + fun `remaining request geometry filter keeps route with uncovered territory`() { + val requestGeometry = geometry("POLYGON ((0 0, 0 2, 2 2, 2 0, 0 0))") + val earlierRoute = route( + noradId = 1L, + time = LocalDateTime.of(2026, 5, 1, 0, 0), + geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))" + ) + val laterRoute = route( + noradId = 2L, + time = LocalDateTime.of(2026, 5, 1, 0, 10), + geometry = "POLYGON ((1 0, 1 1, 2 1, 2 0, 1 0))" + ) + + val result = filterRoutesByRemainingRequestGeometry(listOf(earlierRoute, laterRoute), requestGeometry) + + assertEquals(listOf(earlierRoute, laterRoute), result) + } + + @Test + fun `remaining request geometry filter keeps only routes intersecting uncovered territory`() { + val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))") + val earlyLeftRoute = route( + noradId = 1L, + time = LocalDateTime.of(2026, 5, 1, 0, 0), + geometry = "POLYGON ((0 0, 0 1, 0.5 1, 0.5 0, 0 0))" + ) + val duplicateLeftRoute = route( + noradId = 2L, + time = LocalDateTime.of(2026, 5, 1, 0, 10), + geometry = "POLYGON ((0 0, 0 1, 0.5 1, 0.5 0, 0 0))" + ) + val laterRightRoute = route( + noradId = 3L, + time = LocalDateTime.of(2026, 5, 1, 0, 20), + geometry = "POLYGON ((0.5 0, 0.5 1, 1 1, 1 0, 0.5 0))" + ) + + val result = filterRoutesByRemainingRequestGeometry( + routes = listOf(laterRightRoute, duplicateLeftRoute, earlyLeftRoute), + requestGeometry = requestGeometry + ) + + assertEquals(listOf(earlyLeftRoute, laterRightRoute), result) + } + + @Test + fun `route coverage summary returns last route and covered area percent`() { + val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))") + val earlierRoute = route( + noradId = 1L, + time = LocalDateTime.of(2026, 5, 1, 0, 0), + geometry = "POLYGON ((0 0, 0 1, 0.5 1, 0.5 0, 0 0))" + ) + val laterRoute = route( + noradId = 2L, + time = LocalDateTime.of(2026, 5, 10, 12, 0), + geometry = "POLYGON ((0.5 0, 0.5 1, 1 1, 1 0, 0.5 0))" + ) + + val result = summarizeRouteCoverage(listOf(earlierRoute, laterRoute), requestGeometry) + + assertEquals(laterRoute.time, result.lastRouteStart) + assertEquals(laterRoute.time.plusSeconds(60), result.lastRouteEnd) + assertEquals(100.0, result.coveredAreaPercent ?: -1.0, 0.0001) + } + + @Test + fun `greedy math file filter keeps only selected satellites inside requested interval`() { + val calculationStart = LocalDateTime.of(2026, 5, 1, 10, 0) + val calculationEnd = LocalDateTime.of(2026, 5, 1, 11, 0) + val validMpl = mplItem( + noradId = 101L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 10, 5), + timeEnd = LocalDateTime.of(2026, 5, 1, 10, 10) + ) + val otherSatelliteMpl = mplItem( + noradId = 202L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 10, 5), + timeEnd = LocalDateTime.of(2026, 5, 1, 10, 10) + ) + val startsBeforeIntervalMpl = mplItem( + noradId = 101L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 9, 59), + timeEnd = LocalDateTime.of(2026, 5, 1, 10, 10) + ) + val endsAfterIntervalMpl = mplItem( + noradId = 101L, + gammaMin = 1.0, + timeBegin = LocalDateTime.of(2026, 5, 1, 10, 50), + timeEnd = LocalDateTime.of(2026, 5, 1, 11, 1) + ) + val interval = interval(validMpl, otherSatelliteMpl, startsBeforeIntervalMpl, endsAfterIntervalMpl) + + val result = filterIntervalsForGreedyMathFile( + intervals = listOf(interval), + selectedSatelliteIds = listOf(101L), + calculationStart = calculationStart, + calculationEnd = calculationEnd + ) + + assertEquals(listOf(validMpl), result.single().mpl) + } + + private fun mplItem( + noradId: Long, + gammaMin: Double, + gammaMax: Double = gammaMin, + revSignBegin: RevolutionSign = RevolutionSign.ASC, + revolutionBegin: Long = 1, + timeBegin: LocalDateTime = LocalDateTime.of(2026, 5, 1, 0, 0), + timeEnd: LocalDateTime = LocalDateTime.of(2026, 5, 1, 0, 1) + ) = SquareViewParamDTO( + noradId = noradId, + objectId = noradId.toString(), + revolutionBegin = revolutionBegin, + timeBegin = timeBegin, + revSignBegin = revSignBegin, + revolutionEnd = revolutionBegin, + timeEnd = timeEnd, + revSignEnd = revSignBegin, + gammaMin = gammaMin, + gammaMax = gammaMax + ) + + private fun interval( + vararg mplItems: SquareViewParamDTO, + revSign: RevolutionSign = RevolutionSign.ASC + ) = + IntervalItemDTO( + id = UUID.randomUUID(), + regionId = UUID.randomUUID(), + revSign = revSign, + geometry = "POLYGON EMPTY", + mpl = mplItems.toMutableList() + ) + + private fun route( + noradId: Long, + time: LocalDateTime, + geometry: String + ) = SurveyContour( + mpl = SquareViewParamDTO(noradId = noradId), + time = time, + duration = 60.0, + rev = 1L, + gamma = 0.0, + geometry = geometry + ) + + private fun geometry(value: String) = WKTReader().read(value) +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/CaptureAngleCalculatorTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/CaptureAngleCalculatorTest.kt new file mode 100644 index 0000000..57f928f --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/CaptureAngleCalculatorTest.kt @@ -0,0 +1,43 @@ +package space.nstart.pcp.complan.apl + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CaptureAngleCalculatorTest { + + private val calculator = CaptureAngleCalculator() + + @Test + fun `calculates capture angle from route width and orbital height`() { + val angle = calculator.calculateCaptureAngle( + routeWidthKm = 30.0, + points = listOf(orbPoint(radiusMeters = 6_378_136.0 + 516_000.0)) + ) + + assertEquals(1.665, angle!!, absoluteTolerance = 0.001) + } + + @Test + fun `returns null for empty points`() { + assertNull(calculator.calculateCaptureAngle(routeWidthKm = 30.0, points = emptyList())) + } + + @Test + fun `returns null for non positive route width`() { + assertNull(calculator.calculateCaptureAngle(routeWidthKm = 0.0, points = listOf(orbPoint()))) + } + + private fun orbPoint(radiusMeters: Double = 6_894_136.0) = OrbPointDTO( + time = LocalDateTime.of(2026, 4, 30, 0, 0), + revolution = 1L, + x = radiusMeters, + y = 0.0, + z = 0.0, + vx = 0.0, + vy = 0.0, + vz = 0.0 + ) +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/GeoJsonLayerWriterTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/GeoJsonLayerWriterTest.kt new file mode 100644 index 0000000..c86acc9 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/GeoJsonLayerWriterTest.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.complan.apl + +import org.junit.jupiter.api.io.TempDir +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import space.nstart.pcp.complan.types.GeoJsonItem +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeoJsonLayerWriterTest { + + @Test + fun `does not create layer file when layer writing is disabled`(@TempDir tempDir: Path) { + val layerDir = tempDir.resolve("layers") + val writer = GeoJsonLayerWriter(enabled = false, outputDir = layerDir.toString()) + + writer.write(listOf(testGeoJsonItem()), "Requests") + + assertFalse(Files.exists(layerDir)) + } + + @Test + fun `creates layer file in configured directory when layer writing is enabled`(@TempDir tempDir: Path) { + val layerDir = tempDir.resolve("layers") + val writer = GeoJsonLayerWriter(enabled = true, outputDir = layerDir.toString()) + + writer.write(listOf(testGeoJsonItem()), "Requests") + + assertTrue(Files.exists(layerDir.resolve("Requests.json"))) + } + + private fun testGeoJsonItem(): GeoJsonItem = + GeoJsonItem( + geometry = GeometryFactory().createPoint(Coordinate(37.0, 55.0)), + properties = emptyMap() + ) +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/IntervalCoverageCheckerTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/IntervalCoverageCheckerTest.kt new file mode 100644 index 0000000..ae77e22 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/IntervalCoverageCheckerTest.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.complan.apl + +import org.locationtech.jts.io.WKTReader +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IntervalCoverageCheckerTest { + + private val reader = WKTReader() + private val checker = IntervalCoverageChecker() + + @Test + fun `returns empty geometry when intervals fully cover contour`() { + val contour = reader.read("POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))") + val intervals = listOf( + reader.read("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + reader.read("POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))") + ) + + val uncovered = checker.findUncoveredGeometry(contour, intervals) + + assertTrue(uncovered.isEmpty) + } + + @Test + fun `returns uncovered part when intervals leave gap in contour`() { + val contour = reader.read("POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))") + val intervals = listOf( + reader.read("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))") + ) + + val uncovered = checker.findUncoveredGeometry(contour, intervals) + + assertEquals(1.0, uncovered.area) + } + + @Test + fun `returns full contour when intervals are empty`() { + val contour = reader.read("POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))") + + val uncovered = checker.findUncoveredGeometry(contour, emptyList()) + + assertEquals(contour.area, uncovered.area) + } +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt new file mode 100644 index 0000000..2f59ed7 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt @@ -0,0 +1,73 @@ +package space.nstart.pcp.complan.apl + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.io.TempDir +import space.nstart.pcp.complan.JacksonConfig +import space.nstart.pcp.complan.types.IntervalItemDTO +import space.nstart.pcp.complan.types.ObservationParametersDebugFileDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ObservationParametersDebugFileWriterTest { + + private val objectMapper: ObjectMapper = JacksonConfig().objectMapper() + + @Test + fun `writes observation parameters artifact with intervals and mpl`(@TempDir tempDir: Path) { + val requestId = UUID.fromString("11111111-1111-1111-1111-111111111111") + val intervalId = UUID.fromString("22222222-2222-2222-2222-222222222222") + val writer = ObservationParametersDebugFileWriter( + objectMapper = objectMapper, + outputDir = tempDir.resolve("observation-parameters").toString() + ) + val artifact = ObservationParametersDebugFileDTO( + createdAt = Instant.parse("2026-05-05T10:15:30Z"), + requestId = requestId, + satelliteIds = listOf(1001L), + missingSatelliteIds = emptyList(), + calculationStart = LocalDateTime.parse("2026-05-05T00:00:00"), + calculationEnd = LocalDateTime.parse("2026-05-06T00:00:00"), + parallelBallisticsCalculation = true, + chunkSize = 10, + parallelism = 2, + intervalsCount = 1, + observationParametersCount = 1, + intervals = listOf( + IntervalItemDTO( + id = intervalId, + regionId = UUID.fromString("33333333-3333-3333-3333-333333333333"), + revSign = RevolutionSign.ASC, + geometry = "POLYGON ((0 0, 1 0, 1 1, 0 0))", + mpl = mutableListOf( + SquareViewParamDTO( + noradId = 1001L, + objectId = intervalId.toString(), + revolutionBegin = 42L, + timeBegin = LocalDateTime.parse("2026-05-05T01:00:00"), + revolutionEnd = 42L, + timeEnd = LocalDateTime.parse("2026-05-05T01:01:00") + ) + ) + ) + ) + ) + + val file = writer.write(artifact) + + assertTrue(Files.exists(file)) + assertEquals("observation-parameters-$requestId.json", file.fileName.toString()) + assertEquals(file, writer.filePathFor(requestId)) + val saved = writer.readByRequestId(requestId) + assertEquals(1, saved.schemaVersion) + assertEquals(intervalId, saved.intervals.single().id) + assertEquals(1001L, saved.intervals.single().mpl.single().noradId) + } +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/SurveyContourBuilderTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/SurveyContourBuilderTest.kt new file mode 100644 index 0000000..0c65d88 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/SurveyContourBuilderTest.kt @@ -0,0 +1,85 @@ +package space.nstart.pcp.complan.apl + +import org.junit.jupiter.api.Test +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SurveyContourBuilderTest { + + private val builder = SurveyContourBuilder() + + @Test + fun `build returns closed polygon contour for orbital points`() { + val contour = builder.build( + points = sampleOrbitalPoints(), + roll = 5.0, + capture = 1.5 + ) + + assertNotNull(contour) + val geometry = WKTReader().read(contour) + assertTrue(geometry is Polygon) + assertTrue(geometry.isValid) + assertEquals(geometry.coordinates.first(), geometry.coordinates.last()) + } + + @Test + fun `build returns null when orbital points are not enough`() { + val contour = builder.build( + points = sampleOrbitalPoints().take(1), + roll = 5.0, + capture = 1.5 + ) + + assertNull(contour) + } + + private fun sampleOrbitalPoints() = listOf( + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 0), + revolution = 42L, + x = -6603039.949, + y = -1870023.148, + z = 0.0, + vx = -401.529, + vy = 1413.431, + vz = 7547.696 + ), + orbPoint( + time = LocalDateTime.of(2026, 4, 14, 10, 1), + revolution = 42L, + x = -6595000.0, + y = -1860000.0, + z = 452000.0, + vx = -420.0, + vy = 1390.0, + vz = 7540.0 + ) + ) + + private fun orbPoint( + time: LocalDateTime, + revolution: Long, + x: Double, + y: Double, + z: Double, + vx: Double, + vy: Double, + vz: Double + ) = OrbPointDTO( + time = time, + revolution = revolution, + vx = vx, + vy = vy, + vz = vz, + x = x, + y = y, + z = z + ) +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunServiceTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunServiceTest.kt new file mode 100644 index 0000000..3fde229 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunServiceTest.kt @@ -0,0 +1,439 @@ +package space.nstart.pcp.complan.service + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.complan.apl.BasicPlanCalculator +import space.nstart.pcp.complan.db.entity.DynamicPlanIntervalEntity +import space.nstart.pcp.complan.db.entity.DynamicPlanRouteEntity +import space.nstart.pcp.complan.db.entity.DynamicPlanRunEntity +import space.nstart.pcp.complan.db.repository.DynamicPlanIntervalRepository +import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository +import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository +import space.nstart.pcp.complan.types.ComplexPlanCalculationOutput +import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO +import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO +import space.nstart.pcp.complan.types.ComplexPlanCalculationStatus +import space.nstart.pcp.complan.types.DynamicPlanCalculationMode +import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode +import space.nstart.pcp.complan.types.DynamicPlanRunStatus +import space.nstart.pcp.complan.types.IntervalItemDTO +import space.nstart.pcp.complan.types.SurveyContour +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DynamicPlanRunServiceTest { + + private val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + private val request = ComplexPlanCalculationRequestDTO( + requestId = requestId, + satelliteIds = listOf(101L, 202L), + calculationStart = LocalDateTime.of(2026, 4, 8, 10, 0), + calculationEnd = LocalDateTime.of(2026, 4, 8, 12, 0) + ) + + @Test + fun `startCalculation creates pending run and queues background work`() { + val fixture = fixture() + + val run = fixture.service.startCalculation(request) + + assertEquals(DynamicPlanRunStatus.PENDING, run.status) + assertEquals(requestId, run.requestId) + assertEquals(listOf(101L, 202L), run.satelliteIds) + assertEquals(DynamicPlanCalculationMode.FULL, run.calculationMode) + assertEquals(DynamicPlanRevolutionMode.BOTH, run.revolutionMode) + assertEquals(false, run.filterCoveredRoutes) + assertEquals(1, fixture.executor.queuedTasksCount()) + assertNotNull(fixture.store[run.runId]) + } + + @Test + fun `queued calculation stores completed result`() { + val fixture = fixture() + val result = ComplexPlanCalculationResultDTO( + status = ComplexPlanCalculationStatus.COMPLETED, + requestId = requestId, + satelliteIds = listOf(101L, 202L), + missingSatelliteIds = emptyList(), + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + routesCount = 1, + durationMs = 5000 + ) + val route = SurveyContour( + mpl = SquareViewParamDTO(noradId = 101L), + time = LocalDateTime.of(2026, 4, 8, 10, 10), + duration = 30.0, + rev = 55L, + gamma = 11.5, + geometry = "POLYGON ((0 0, 0 1, 1 1, 0 0))" + ) + val interval = IntervalItemDTO( + id = UUID.fromString("33333333-3333-4333-8333-333333333333"), + regionId = UUID.fromString("44444444-4444-4444-8444-444444444444"), + revSign = RevolutionSign.ASC, + geometry = "POLYGON ((0 0, 0 2, 2 2, 0 0))", + mpl = mutableListOf(SquareViewParamDTO(noradId = 101L)) + ) + doReturn(ComplexPlanCalculationOutput(result = result, routes = listOf(route), intervals = listOf(interval))) + .`when`(fixture.basePlan).calculate(request) + + val run = fixture.service.startCalculation(request) + fixture.executor.runNext() + + val storedRun = fixture.service.getRun(run.runId) + val storedResult = fixture.service.getResult(run.runId) + val storedRoutes = fixture.service.getRoutes(run.runId, limit = 1000, offset = 0) + val storedIntervals = fixture.service.getIntervals(run.runId, limit = 1000, offset = 0) + + assertEquals(DynamicPlanRunStatus.COMPLETED, storedRun.status) + assertEquals("COMPLETED", storedRun.resultStatus) + assertEquals(1, storedRun.routesCount) + assertEquals(5000, storedRun.durationMs) + assertNull(storedRun.errorMessage) + assertEquals(result, storedResult) + assertEquals(1, storedRoutes.size) + assertEquals(101L, storedRoutes[0].satelliteId) + assertEquals("POLYGON ((0 0, 0 1, 1 1, 0 0))", storedRoutes[0].contourWkt) + assertEquals(1, storedIntervals.size) + assertEquals(interval.id, storedIntervals[0].intervalId) + assertEquals(RevolutionSign.ASC, storedIntervals[0].revSign) + assertEquals(1, storedIntervals[0].observationParametersCount) + verify(fixture.aplSettingsLoader).loadLatestSettings() + verify(fixture.basePlan).calculate(request) + } + + @Test + fun `queued calculation keeps selected calculation mode`() { + val fixture = fixture() + val greedyRequest = request.copy( + calculationMode = DynamicPlanCalculationMode.GREEDY, + revolutionMode = DynamicPlanRevolutionMode.DESC, + filterCoveredRoutes = true + ) + val result = ComplexPlanCalculationResultDTO( + status = ComplexPlanCalculationStatus.COMPLETED, + requestId = requestId, + satelliteIds = listOf(101L, 202L), + missingSatelliteIds = emptyList(), + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + calculationMode = DynamicPlanCalculationMode.GREEDY, + revolutionMode = DynamicPlanRevolutionMode.DESC, + filterCoveredRoutes = true, + routesCount = 0, + durationMs = 1000 + ) + doReturn(ComplexPlanCalculationOutput(result = result, routes = emptyList())).`when`(fixture.basePlan).calculate(greedyRequest) + + val run = fixture.service.startCalculation(greedyRequest) + fixture.executor.runNext() + + val storedRun = fixture.service.getRun(run.runId) + val storedResult = fixture.service.getResult(run.runId) + + assertEquals(DynamicPlanCalculationMode.GREEDY, storedRun.calculationMode) + assertEquals(DynamicPlanRevolutionMode.DESC, storedRun.revolutionMode) + assertEquals(true, storedRun.filterCoveredRoutes) + assertEquals(DynamicPlanCalculationMode.GREEDY, storedResult.calculationMode) + assertEquals(DynamicPlanRevolutionMode.DESC, storedResult.revolutionMode) + assertEquals(true, storedResult.filterCoveredRoutes) + verify(fixture.basePlan).calculate(greedyRequest) + } + + @Test + fun `getResult reads legacy result json without calculation mode`() { + val fixture = fixture() + val runId = UUID.fromString("55555555-5555-4555-8555-555555555555") + fixture.store[runId] = runEntity( + id = runId, + status = DynamicPlanRunStatus.COMPLETED + ).apply { + resultJson = """ + { + "status": "COMPLETED", + "requestId": "$requestId", + "satelliteIds": [101, 202], + "missingSatelliteIds": [], + "calculationStart": "2026-04-08T10:00:00", + "calculationEnd": "2026-04-08T12:00:00", + "routesCount": 1, + "durationMs": 5000 + } + """.trimIndent() + } + + val result = fixture.service.getResult(runId) + + assertEquals(DynamicPlanCalculationMode.FULL, result.calculationMode) + assertEquals(DynamicPlanRevolutionMode.BOTH, result.revolutionMode) + assertEquals(false, result.filterCoveredRoutes) + assertEquals(1, result.routesCount) + } + + @Test + fun `getResult rejects unfinished run`() { + val fixture = fixture() + val run = fixture.service.startCalculation(request) + + assertFailsWith { + fixture.service.getResult(run.runId) + } + } + + @Test + fun `listRuns returns latest runs and supports status filter`() { + val fixture = fixture() + val olderRun = runEntity( + id = UUID.fromString("22222222-2222-4222-8222-222222222222"), + status = DynamicPlanRunStatus.COMPLETED, + createdAt = LocalDateTime.of(2026, 4, 8, 9, 0) + ) + val newerRun = runEntity( + id = UUID.fromString("33333333-3333-4333-8333-333333333333"), + status = DynamicPlanRunStatus.FAILED, + createdAt = LocalDateTime.of(2026, 4, 8, 10, 0) + ) + fixture.store[olderRun.id!!] = olderRun + fixture.store[newerRun.id!!] = newerRun + + val allRuns = fixture.service.listRuns(status = null, limit = 10, offset = 0) + val failedRuns = fixture.service.listRuns(status = DynamicPlanRunStatus.FAILED, limit = 10, offset = 0) + + assertEquals(listOf(newerRun.id, olderRun.id), allRuns.map { run -> run.runId }) + assertEquals(listOf(newerRun.id), failedRuns.map { run -> run.runId }) + } + + @Test + fun `recoverInterruptedRuns marks pending and running runs as failed`() { + val fixture = fixture() + val pendingRun = runEntity( + id = UUID.fromString("22222222-2222-4222-8222-222222222222"), + status = DynamicPlanRunStatus.PENDING + ) + val runningRun = runEntity( + id = UUID.fromString("33333333-3333-4333-8333-333333333333"), + status = DynamicPlanRunStatus.RUNNING + ) + val completedRun = runEntity( + id = UUID.fromString("44444444-4444-4444-8444-444444444444"), + status = DynamicPlanRunStatus.COMPLETED + ) + fixture.store[pendingRun.id!!] = pendingRun + fixture.store[runningRun.id!!] = runningRun + fixture.store[completedRun.id!!] = completedRun + + fixture.service.recoverInterruptedRuns() + + assertEquals(DynamicPlanRunStatus.FAILED, pendingRun.status) + assertEquals(DynamicPlanRunStatus.FAILED, runningRun.status) + assertEquals(DynamicPlanRunStatus.COMPLETED, completedRun.status) + assertEquals("Calculation was interrupted by service restart", pendingRun.errorMessage) + assertEquals("Calculation was interrupted by service restart", runningRun.errorMessage) + assertNotNull(pendingRun.finishedAt) + assertNotNull(runningRun.finishedAt) + assertNull(completedRun.finishedAt) + } + + private fun fixture(): Fixture { + val store = linkedMapOf() + val routeStore = mutableListOf() + val intervalStore = mutableListOf() + val repository = mock(DynamicPlanRunRepository::class.java) + val routeRepository = mock(DynamicPlanRouteRepository::class.java) + val intervalRepository = mock(DynamicPlanIntervalRepository::class.java) + org.mockito.Mockito.`when`( + repository.save(ArgumentMatchers.any(DynamicPlanRunEntity::class.java) ?: DynamicPlanRunEntity(id = UUID.randomUUID())) + ).thenAnswer { invocation -> + val entity = invocation.arguments[0] as DynamicPlanRunEntity + store[entity.id!!] = entity + entity + } + org.mockito.Mockito.`when`( + repository.findById(ArgumentMatchers.any(UUID::class.java) ?: UUID.randomUUID()) + ).thenAnswer { invocation -> + Optional.ofNullable(store[invocation.arguments[0] as UUID]) + } + org.mockito.Mockito.`when`( + repository.saveAll(ArgumentMatchers.anyIterable() ?: emptyList()) + ).thenAnswer { invocation -> + val entities = invocation.arguments[0] as Iterable + entities.map { entity -> + store[entity.id!!] = entity + entity + } + } + org.mockito.Mockito.`when`( + repository.findRuns(ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt()) + ).thenAnswer { invocation -> + val limit = invocation.arguments[0] as Int + val offset = invocation.arguments[1] as Int + store.values + .sortedByDescending { run -> run.createdAt } + .drop(offset) + .take(limit) + } + org.mockito.Mockito.`when`( + repository.findRunsByStatus( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyInt(), + ArgumentMatchers.anyInt() + ) + ).thenAnswer { invocation -> + val status = DynamicPlanRunStatus.valueOf(invocation.arguments[0] as String) + val limit = invocation.arguments[1] as Int + val offset = invocation.arguments[2] as Int + store.values + .filter { run -> run.status == status } + .sortedByDescending { run -> run.createdAt } + .drop(offset) + .take(limit) + } + org.mockito.Mockito.`when`( + repository.findAllByStatusIn(ArgumentMatchers.anyCollection() ?: emptyList()) + ).thenAnswer { invocation -> + val statuses = invocation.arguments[0] as Collection + store.values.filter { run -> run.status in statuses } + } + org.mockito.Mockito.`when`( + routeRepository.saveAll(ArgumentMatchers.anyList() ?: emptyList()) + ).thenAnswer { invocation -> + val entities = invocation.arguments[0] as Iterable + entities.map { entity -> + entity.id = (routeStore.size + 1).toLong() + routeStore.add(entity) + entity + } + } + org.mockito.Mockito.`when`( + intervalRepository.saveAll(ArgumentMatchers.anyList() ?: emptyList()) + ).thenAnswer { invocation -> + val entities = invocation.arguments[0] as Iterable + entities.map { entity -> + entity.id = (intervalStore.size + 1).toLong() + intervalStore.add(entity) + entity + } + } + org.mockito.Mockito.`when`( + routeRepository.findRoutesByRunId( + ArgumentMatchers.any(UUID::class.java) ?: UUID.randomUUID(), + ArgumentMatchers.anyInt(), + ArgumentMatchers.anyInt() + ) + ) + .thenAnswer { invocation -> + val runId = invocation.arguments[0] as UUID + routeStore.filter { route -> route.run?.id == runId }.sortedWith(compareBy({ it.startTime }, { it.id })) + } + org.mockito.Mockito.`when`( + intervalRepository.findIntervalsByRunId( + ArgumentMatchers.any(UUID::class.java) ?: UUID.randomUUID(), + ArgumentMatchers.anyInt(), + ArgumentMatchers.anyInt() + ) + ) + .thenAnswer { invocation -> + val runId = invocation.arguments[0] as UUID + intervalStore.filter { interval -> interval.run?.id == runId }.sortedWith(compareBy({ it.intervalOrder }, { it.id })) + } + + val basePlan = mock(BasicPlanCalculator::class.java) + val aplSettingsLoader = mock(AplSettingsLoader::class.java) + val executor = QueueingExecutorService() + val service = DynamicPlanRunService( + runRepository = repository, + routeRepository = routeRepository, + intervalRepository = intervalRepository, + basePlan = basePlan, + aplSettingsLoader = aplSettingsLoader, + objectMapper = ObjectMapper().findAndRegisterModules(), + transactionTemplate = TransactionTemplate(NoopTransactionManager()), + dynamicPlanExecutor = executor + ) + + return Fixture(service, basePlan, aplSettingsLoader, executor, store) + } + + private fun runEntity( + id: UUID, + status: DynamicPlanRunStatus, + createdAt: LocalDateTime = LocalDateTime.of(2026, 4, 8, 10, 0) + ): DynamicPlanRunEntity = + DynamicPlanRunEntity( + id = id, + status = status, + requestId = requestId, + satelliteIds = """[101,202]""", + calculationStart = request.calculationStart, + calculationEnd = request.calculationEnd, + createdAt = createdAt + ) + + private data class Fixture( + val service: DynamicPlanRunService, + val basePlan: BasicPlanCalculator, + val aplSettingsLoader: AplSettingsLoader, + val executor: QueueingExecutorService, + val store: MutableMap + ) + + private class QueueingExecutorService : AbstractExecutorService() { + private val tasks = ArrayDeque() + private var shutdown = false + + override fun execute(command: Runnable) { + tasks.add(command) + } + + fun queuedTasksCount(): Int = tasks.size + + fun runNext() { + tasks.removeFirst().run() + } + + override fun shutdown() { + shutdown = true + } + + override fun shutdownNow(): MutableList { + shutdown = true + val queued = tasks.toMutableList() + tasks.clear() + return queued + } + + override fun isShutdown(): Boolean = shutdown + + override fun isTerminated(): Boolean = shutdown && tasks.isEmpty() + + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = isTerminated + } + + private class NoopTransactionManager : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + + override fun commit(status: TransactionStatus) = Unit + + override fun rollback(status: TransactionStatus) = Unit + } +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt new file mode 100644 index 0000000..03c19b7 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt @@ -0,0 +1,111 @@ +package space.nstart.pcp.complan.service + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.test.util.ReflectionTestUtils +import java.net.InetSocketAddress +import java.net.URI +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class RequestServiceClientTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `getRequest calls v1 request endpoint and maps response to request item`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000001") + val requestedUris = mutableListOf() + server = serverWithRequestResponse(requestedUris, requestId) + + val request = requestServiceClient().getRequest(requestId)!! + + assertEquals("/v1/requests/$requestId", requestedUris.single().path) + assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) + assertEquals(requestId, request.id) + assertEquals("Dynamic plan request", request.name) + assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", request.geometry) + assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin) + assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd) + assertEquals(2.5, request.resolution) + assertEquals(7.5, request.importance) + } + + @Test + fun `getRequest returns null on not found like legacy client`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000404") + val requestedUris = mutableListOf() + server = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/v1/requests") { exchange -> + requestedUris += exchange.requestURI + val responseBody = """{"code":"REQUEST_NOT_FOUND"}""" + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(404, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } + start() + } + + val request = requestServiceClient().getRequest(requestId) + + assertNull(request) + assertEquals("/v1/requests/$requestId", requestedUris.single().path) + assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) + } + + private fun serverWithRequestResponse( + requestedUris: MutableList, + requestId: UUID, + ): HttpServer = + HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/v1/requests") { exchange -> + requestedUris += exchange.requestURI + val responseBody = """ + { + "id": "$requestId", + "name": "Dynamic plan request", + "status": "ACTIVE", + "surveyType": "OPTICS", + "geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "importance": 7.5, + "beginDateTime": "2026-01-02T03:04:05Z", + "endDateTime": "2026-01-03T04:05:06Z", + "kpp": [1], + "highPriorityTransmit": false, + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 2.5, + "sunAngleMin": 10.0, + "sunAngleMax": 90.0, + "clouds": 100.0 + }, + "rsa": null, + "coverage": {"currentPercent": 0.0}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "deletedAt": null + } + """.trimIndent() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(responseBody.toByteArray()) } + } + start() + } + + private fun requestServiceClient(): RequestServiceClient { + val client = RequestServiceClient() + ReflectionTestUtils.setField(client, "requestServiceUrl", "http://localhost:${server!!.address.port}") + ReflectionTestUtils.setField(client, "requestSelectionPath", "/v1/requests") + return client + } +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedServiceTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedServiceTest.kt new file mode 100644 index 0000000..892a282 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedServiceTest.kt @@ -0,0 +1,33 @@ +package space.nstart.pcp.complan.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository +import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository + +class SatelliteDeletedServiceTest { + + @Test + fun `deleteSatelliteData removes dynamic plan runs and satellite routes`() { + val runRepository = mock(DynamicPlanRunRepository::class.java) + val routeRepository = mock(DynamicPlanRouteRepository::class.java) + val service = SatelliteDeletedService(runRepository, routeRepository) + + `when`(runRepository.countBySatelliteId(56756L)).thenReturn(2L) + `when`(routeRepository.countBySatelliteId(56756L)).thenReturn(5L) + `when`(runRepository.deleteAllBySatelliteId(56756L)).thenReturn(2) + `when`(routeRepository.deleteAllBySatelliteId(56756L)).thenReturn(1) + + val summary = service.deleteSatelliteData(56756L) + + assertEquals(56756L, summary.satelliteId) + assertEquals(2, summary.runsDeleted) + assertEquals(4L, summary.routesDeletedByRunCascade) + assertEquals(1, summary.orphanRoutesDeleted) + verify(runRepository).deleteAllBySatelliteId(56756L) + verify(routeRepository).deleteAllBySatelliteId(56756L) + } +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraftTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraftTest.kt new file mode 100644 index 0000000..d8295bb --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraftTest.kt @@ -0,0 +1,75 @@ +package space.nstart.pcp.complan.spacecraft + +import org.locationtech.jts.geom.Coordinate +import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SpaceCraftTest { + + @Test + fun `spacecraft has default planning constraints`() { + val spacecraft = SpaceCraft(56756) + + assertEquals(SCConstraints(), spacecraft.constraints) + } + + @Test + fun `create flight line selects full passes split by revolution`() { + val spacecraft = SpaceCraft(56756) + spacecraft.flightLine.addAll( + listOf( + point(RevolutionSign.ASC, 1, -70.0, 150.0), + point(RevolutionSign.ASC, 1, 82.0, 20.0), + point(RevolutionSign.DESC, 1, 82.0, 20.0), + point(RevolutionSign.DESC, 1, 30.0, 5.0), + point(RevolutionSign.DESC, 2, -82.0, 350.0), + point(RevolutionSign.ASC, 2, -82.0, 350.0), + point(RevolutionSign.ASC, 2, 30.0, 20.0), + point(RevolutionSign.ASC, 2, 82.0, 50.0), + ) + ) + + val (desc, asc) = spacecraft.createFlightLine() + + assertTrue(desc.coordinates.minOf { it.y } <= -80.0) + assertTrue(desc.coordinates.maxOf { it.y } >= 80.0) + assertTrue(asc.coordinates.minOf { it.y } <= -80.0) + assertTrue(asc.coordinates.maxOf { it.y } >= 80.0) + } + + @Test + fun `line correction unwraps longitude jumps in both directions`() { + val spacecraft = SpaceCraft(56756) + + val corrected = spacecraft.lineCorrection( + listOf( + Coordinate(350.0, 0.0), + Coordinate(10.0, 1.0), + Coordinate(340.0, 2.0), + ) + ) + + assertTrue(corrected[1].x > 350.0) + assertTrue(corrected[2].x < corrected[1].x) + assertTrue(kotlin.math.abs(corrected[1].x - corrected[0].x) < 180.0) + assertTrue(kotlin.math.abs(corrected[2].x - corrected[1].x) < 180.0) + } + + private fun point( + revSign: RevolutionSign, + revolution: Long, + lat: Double, + long: Double + ): FlightLineDTO = + FlightLineDTO( + time = LocalDateTime.of(2026, 4, 30, 0, 0), + revolution = revolution, + lat = lat, + long = long, + revSign = revSign + ) +} diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/types/RequestItemDTOTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/types/RequestItemDTOTest.kt new file mode 100644 index 0000000..4f0a942 --- /dev/null +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/types/RequestItemDTOTest.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp.complan.types + +import org.junit.jupiter.api.Test +import space.nstart.pcp.complan.JacksonConfig +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RequestItemDTOTest { + + private val objectMapper = JacksonConfig().objectMapper() + + @Test + fun `request item supports nullable priority and intervals from request service`() { + val requestId = UUID.fromString("39ef7800-d20c-4aae-8041-00208c4551b2") + val body = """ + { + "id": "$requestId", + "name": "Гибралтар", + "geometry": "POLYGON ((-5.909782 35.691644, -4.797418 35.691644, -4.797418 36.589956, -5.909782 36.589956, -5.909782 35.691644))", + "intervalBegin": "2000-01-01T00:00:00", + "intervalEnd": "2100-01-01T00:00:00", + "resolution": 1.0, + "priority": null, + "importance": 0.5, + "appType": 0, + "intervals": null + } + """.trimIndent() + + val result = objectMapper.readValue(body, RequestItemDTO::class.java) + + assertEquals(requestId, result.id) + assertEquals("Гибралтар", result.name) + assertNull(result.priority) + assertNull(result.intervals) + } +} diff --git a/services/pcp-mission-planing-service/Dockerfile b/services/pcp-mission-planing-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-mission-planing-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-mission-planing-service/build.gradle.kts b/services/pcp-mission-planing-service/build.gradle.kts new file mode 100644 index 0000000..174b89c --- /dev/null +++ b/services/pcp-mission-planing-service/build.gradle.kts @@ -0,0 +1,82 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +version = "1.0.0" + + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation(project(":libs:ballistics-lib")) + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}") + + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.kafka:spring-kafka") + implementation("io.camunda:camunda-spring-boot-4-starter:8.8.10") + implementation("org.springframework.cloud:spring-cloud-starter-config") + + + implementation("org.locationtech.jts:jts-core:1.19.0") + + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + testRuntimeOnly("com.h2database:h2") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt new file mode 100644 index 0000000..22aaa4e --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_mission_planing_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.kafka.annotation.EnableKafka + +@EnableKafka +@SpringBootApplication +class PcpMissionPlaningServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/configuration/KafkaConfig.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/configuration/KafkaConfig.kt new file mode 100644 index 0000000..58c1c12 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/configuration/KafkaConfig.kt @@ -0,0 +1,88 @@ +package space.nstart.pcp.pcp_mission_planing_service.configuration + +import org.apache.kafka.clients.admin.AdminClientConfig +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaAdmin +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + + @Value("\${app.kafka.topics.booked-slots}") + private lateinit var bookedSlotsTopic: String + + @Value("\${app.kafka.topics.mode-status-changed:pcp.route.in.v1}") + private lateinit var modeStatusChangedTopic: String + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Value("\${spring.kafka.consumer.auto-offset-reset:latest}") + private lateinit var autoOffsetReset: String + + @Value("\${spring.kafka.consumer.enable-auto-commit:false}") + private var enableAutoCommit: Boolean = false + + @Bean + fun bookedSlotsTopic(): NewTopic = NewTopic(bookedSlotsTopic, 1, 1) + + @Bean + fun modeStatusChangedTopic(): NewTopic = NewTopic(modeStatusChangedTopic, 1, 1) + + @Bean + fun kafkaAdmin(): KafkaAdmin = + KafkaAdmin( + mutableMapOf( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers + ) + ) + + @Bean + fun consumerFactory(): ConsumerFactory = + DefaultKafkaConsumerFactory( + mutableMapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to enableAutoCommit, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java + ) + ) + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + setConsumerFactory(consumerFactory()) + } + + @Bean + fun producerFactory(): ProducerFactory = + DefaultKafkaProducerFactory( + mutableMapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java + ) + ) + + @Bean + fun kafkaTemplate(): KafkaTemplate = KafkaTemplate(producerFactory()) +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt new file mode 100644 index 0000000..419f7cb --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -0,0 +1,181 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +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_types_lib.dto.satellite.mission.ModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService +import java.time.LocalDateTime +import java.util.UUID + + +@RestController +@RequestMapping("/api/missions") +@Tag(name = "Missions", description = "Управление миссиями и режимами") +class MissionController( + private val missionPlaningService: MissionPlaningService +) { + + @Operation(summary = "Получить список миссий") + @GetMapping + fun all() = missionPlaningService.all() + + @Operation(summary = "Получить миссию по id") + @GetMapping("/{mission_id}") + fun byId( + @Parameter(description = "Идентификатор миссии") + @PathVariable("mission_id") id: UUID + ) = missionPlaningService.byId(id) + + @Operation(summary = "Получить режимы миссии") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Список режимов (SURVEY и Drop)", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = ModeResponseDTO::class)) + ) + ] + ), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @GetMapping("/{mission_id}/modes") + fun modes( + @Parameter(description = "Идентификатор миссии") + @PathVariable("mission_id") id: UUID + ) = missionPlaningService.modesByMission(id) + + @Operation(summary = "Получить режимы аппарата за интервал времени") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Список режимов (SURVEY и DROP) для аппарата в указанном интервале", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = ModeResponseDTO::class)) + ) + ] + ), + ApiResponse(responseCode = "400", description = "Некорректный интервал времени") + ] + ) + @GetMapping("/modes/satellite") + fun modesBySatelliteAndInterval( + @Parameter(description = "Идентификатор аппарата") + @RequestParam("satellite_id") satelliteId: Long, + @Parameter(description = "Начало интервала") + @RequestParam timeStart: LocalDateTime, + @Parameter(description = "Конец интервала") + @RequestParam timeStop: LocalDateTime + ) = missionPlaningService.modesBySatelliteAndInterval(satelliteId, timeStart, timeStop) + + @Operation(summary = "Запустить расчет сбросов для миссии") + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Расчет выполнен"), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @PostMapping("/{mission_id}/drops/calculate") + fun calculateDrops( + @Parameter(description = "Идентификатор миссии") + @PathVariable("mission_id") id: UUID + ): ResponseEntity { + missionPlaningService.calculateDrops(id) + return ResponseEntity.noContent().build() + } + + + @Operation(summary = "Запустить расчет плана съемки для миссии") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Расчет выполнен", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = MissionSurveyCalculationResponseDTO::class) + ) + ] + ), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @PostMapping("/{mission_id}/surveys/calculate") + fun calculateSurveys( + @Parameter(description = "Идентификатор миссии") + @PathVariable("mission_id") id: UUID, + @Parameter(description = "Конкретный snapshot этапа 4") + @RequestParam(name = "comPlanSnapshotId", required = false) comPlanSnapshotId: Long? + ): MissionSurveyCalculationResponseDTO = + missionPlaningService.loadSlots(id, comPlanSnapshotId = comPlanSnapshotId) + + @Operation(summary = "Подтвердить миссию") + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Миссия подтверждена"), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @PostMapping("/{mission_id}/confirm") + fun confirmMission( + @Parameter(description = "Идентификатор миссии") + @PathVariable("mission_id") id: UUID + ): ResponseEntity { + missionPlaningService.confirmMission(id) + return ResponseEntity.noContent().build() + } + + @Operation(summary = "Изменить статус режима съемки") + @PutMapping("/survey-modes/{mode_id}/status") + fun updateSurveyModeStatus( + @PathVariable("mode_id") id: Long, + @Valid @RequestBody body: SurveyModeStatusUpdateRequest + ): ResponseEntity { + missionPlaningService.updateSurveyModeStatus(id, body.status) + return ResponseEntity.noContent().build() + } + + + + @Operation(summary = "Создать миссию") + @PostMapping + fun add(@Valid @RequestBody body: MissionRequestDTO) = missionPlaningService.add(body) + + @Operation(summary = "Обновить миссию") + @PutMapping("/{mission_id}") + fun update(@PathVariable("mission_id") id: UUID, @Valid @RequestBody body: MissionRequestDTO) = + missionPlaningService.update(id, body) + + @Operation(summary = "Удалить миссию") + @DeleteMapping("/{mission_id}") + fun delete(@PathVariable("mission_id") id: UUID): ResponseEntity { + missionPlaningService.delete(id) + return ResponseEntity.noContent().build() + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/SurveyModeStatusUpdateRequest.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/SurveyModeStatusUpdateRequest.kt new file mode 100644 index 0000000..95bdf4d --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/SurveyModeStatusUpdateRequest.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import jakarta.validation.constraints.NotNull +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus + +data class SurveyModeStatusUpdateRequest( + @field:NotNull + val status: SurveyModeStatus = SurveyModeStatus.PLANNED +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt new file mode 100644 index 0000000..8a614ea --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.time.OffsetDateTime + +@Entity +@Table(name = "drop_modes") +@DiscriminatorValue("DROP") +class DropModeEntity( + id: Long? = null, + plan: PlanEntity? = null, + timeStart: OffsetDateTime? = null, + revolution: Long = 0, + + @Column(name = "station") + var station: String? = null, + + @Column(name = "duration", nullable = false) + var duration: Double = 0.0 +) : ModeEntity(id, plan, timeStart, revolution) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeSurveyEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeSurveyEntity.kt new file mode 100644 index 0000000..5112b29 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeSurveyEntity.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "drop_mode_survey") +class DropModeSurveyEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "drop_mode_id", nullable = false) + var dropMode: DropModeEntity? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "survey_mode_id", nullable = false) + var surveyMode: SurveyModeEntity? = null +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/MissionEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/MissionEntity.kt new file mode 100644 index 0000000..2eabcfa --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/MissionEntity.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "missions") +class MissionEntity( + @Id + @Column(name = "mission_id", nullable = false, updatable = false) + val missionId: UUID = UUID.randomUUID(), + + @Column(name = "satellite_id", nullable = false) + var satelliteId: Long = 0, + + @Column(name = "station", length = 32) + var station: String? = null, + + @Column(name = "mission_start") + var missionStart: LocalDateTime? = null, + + @Column(name = "mission_stop") + var missionStop: LocalDateTime? = null, + + @Column(name = "status", length = 15) + var status: String = "NEW" +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeBookingEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeBookingEntity.kt new file mode 100644 index 0000000..444144c --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeBookingEntity.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "mode_booking") +class ModeBookingEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mode_id", nullable = false) + var mode: SurveyModeEntity? = null, + + @Column(name = "slot_id", nullable = false) + var slotId: Long = 0 +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt new file mode 100644 index 0000000..75daeec --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorColumn +import jakarta.persistence.DiscriminatorType +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Inheritance +import jakarta.persistence.InheritanceType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.OffsetDateTime + +@Entity +@Table(name = "modes") +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.STRING, length = 20) +abstract class ModeEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + open val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "plan_id", nullable = false) + open var plan: PlanEntity? = null, + + @Column(name = "time_start") + open var timeStart: OffsetDateTime? = null, + + @Column(name = "revolution", nullable = false) + open var revolution: Long = 0 +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/PlanEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/PlanEntity.kt new file mode 100644 index 0000000..73aa43c --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/PlanEntity.kt @@ -0,0 +1,39 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "plans") +class PlanEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_id", nullable = false) + var mission: MissionEntity? = null, + + @Column(name = "plan_start") + var planStart: LocalDateTime? = null, + + @Column(name = "plan_stop") + var planStop: LocalDateTime? = null, + + @Column(name = "plan_number", nullable = false) + var planNumber: Long = 0, + + @Column(name = "revolution", nullable = false) + var revolution: Long = 0, + + @Column(name = "stage4_snapshot_id") + var stage4SnapshotId: Long? = null +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt new file mode 100644 index 0000000..ee053fc --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt @@ -0,0 +1,49 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import java.time.OffsetDateTime + +@Entity +@Table(name = "survey_modes") +@DiscriminatorValue("SURVEY") +class SurveyModeEntity( + id: Long? = null, + plan: PlanEntity? = null, + timeStart: OffsetDateTime? = null, + revolution: Long = 0, + + @Column(name = "lat", nullable = false) + var lat: Double = 0.0, + + @Column(name = "long", nullable = false) + var long: Double = 0.0, + + @Column(name = "duration", nullable = false) + var duration: Double = 0.0, + + @Column(name = "contour_wkt", columnDefinition = "TEXT") + var contourWkt: String? = null, + + @Column(name = "contour_geometry", insertable = false, updatable = false, columnDefinition = "geometry(Polygon, 4326)") + var contourGeometry: String? = null, + + @Column(name = "roll", nullable = false) + var roll: Double = 0.0, + + @Column(name = "satellite_mode_id") + var satelliteModeId: Long? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + var status: SurveyModeStatus = SurveyModeStatus.PLANNED, + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 20) + var source: SurveyModeSource = SurveyModeSource.SLOT +) : ModeEntity(id, plan, timeStart, revolution) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeSource.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeSource.kt new file mode 100644 index 0000000..72886c0 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeSource.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_mission_planing_service.entity + +enum class SurveyModeSource { + USER, + SLOT, + HEAT_MAP, + SLOTS, + COMPLAN, + MIXED, + MANUAL +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeRepository.kt new file mode 100644 index 0000000..a56b5df --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeRepository.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity + +interface DropModeRepository : JpaRepository { + fun findAllByPlan_IdOrderByTimeStartAsc(planId: Long): List +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt new file mode 100644 index 0000000..45b3850 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import java.util.UUID + +interface DropModeSurveyRepository : JpaRepository { + fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List + fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/MissionRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/MissionRepository.kt new file mode 100644 index 0000000..5b768fc --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/MissionRepository.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity +import java.util.UUID + +interface MissionRepository : JpaRepository { + fun countBySatelliteId(satelliteId: Long): Long + fun deleteAllBySatelliteId(satelliteId: Long): Long +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeBookingRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeBookingRepository.kt new file mode 100644 index 0000000..9415ea0 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeBookingRepository.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity +import java.util.UUID + +interface ModeBookingRepository : JpaRepository { + fun findAllByMode_IdIn(modeIds: Collection): List + fun findAllByMode_Plan_Mission_MissionId(missionId: UUID): List +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt new file mode 100644 index 0000000..a5bd595 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity +import java.time.OffsetDateTime +import java.util.UUID + +interface ModeRepository : JpaRepository { + fun findAllByPlan_IdOrderByTimeStartAsc(planId: Long): List + fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List + fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( + satelliteId: Long, + timeStop: OffsetDateTime + ): List + + fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long + + @Modifying + @Transactional + @Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true) + fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int + + @Modifying + @Transactional + @Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true) + fun deleteDropModesByPlanId(@Param("planId") planId: Long): Int +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/PlanRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/PlanRepository.kt new file mode 100644 index 0000000..53d78aa --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/PlanRepository.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import java.util.Optional +import java.util.UUID + +interface PlanRepository : JpaRepository { + fun findAllByMissionMissionIdOrderByPlanNumberAsc(missionId: UUID): List + fun findFirstByMissionMissionIdOrderByIdAsc(missionId: UUID): Optional + fun findFirstByMissionMissionIdAndPlanNumber(missionId: UUID, planNumber: Long): Optional + fun countByMissionSatelliteId(satelliteId: Long): Long +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt new file mode 100644 index 0000000..b264ef1 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp.pcp_mission_planing_service.repository + +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.query.Param +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import java.time.OffsetDateTime +import java.util.UUID + +interface SurveyModeRepository : JpaRepository { + fun findAllByPlan_IdOrderByTimeStartAsc(planId: Long): List + fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List + + @Query( + """ + select sm + from SurveyModeEntity sm + join sm.plan p + join p.mission m + where concat('', m.satelliteId) = :satelliteNumber + and sm.revolution = :revolution + and sm.timeStart between :timeStartFrom and :timeStartTo + order by sm.timeStart asc, sm.id asc + """ + ) + fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( + @Param("satelliteNumber") satelliteNumber: String, + @Param("revolution") revolution: Long, + @Param("timeStartFrom") timeStartFrom: OffsetDateTime, + @Param("timeStartTo") timeStartTo: OffsetDateTime + ): List +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BallisticsService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BallisticsService.kt new file mode 100644 index 0000000..e991305 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BallisticsService.kt @@ -0,0 +1,85 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToFlux +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamsWithPointsDTO +import java.time.LocalDateTime + + +@Service +class BallisticsService(webClientBuilderProvider: ObjectProvider){ + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.ballistics-service:ic-service}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + + fun mplSquare(req : ObjViewRequestDTO) = + client() + .post() + .uri("/api/obj-view/mpl-square") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux() + .toIterable() + + + fun mplPoints(req : ObjViewRequestDTO) = + client() + .post() + .uri("/api/obj-view/mpl-point") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux() + .toIterable() + + + fun rva(id : Long, + tn : LocalDateTime, + tk : LocalDateTime) = + client() + .get() + .uri("/api/satellites/{id}/rva?time_start={time_start}&time_stop={time_stop}", + id, + tn, + tk) + .retrieve() + .bodyToFlux() + .toIterable() + + + fun cellCovering(cell : Long) = + client() + .get() + .uri("/api/obj-view/cell-coverage/{cell}", cell) + .retrieve() + .bodyToFlux() + .toIterable() + + + fun exactTime(sat : Long, req : ExactTimePositionRequestDTO) = + client() + .post() + .uri("/api/satellites/{satelliteId}/extract-time", sat) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToFlux() + .toIterable() + + +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt new file mode 100644 index 0000000..4587cc3 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt @@ -0,0 +1,93 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.util.UUID + +@Component +/** + * Kafka-first publisher for booked slots lifecycle updates. + * Legacy HTTP processing in slots-service is retired from the main pipeline. + */ +class BookedSlotsEventPublisher( + kafkaTemplateProvider: ObjectProvider>, + objectMapperProvider: ObjectProvider, + @Value("\${app.kafka.topics.booked-slots:pcp.booked-slots}") private val topic: String, + @Value("\${spring.application.name:pcp-mission-planing-service}") private val applicationName: String +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val kafkaTemplate = kafkaTemplateProvider.ifAvailable + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + fun publish( + bookedSlotIds: List, + status: BookedSlotStatus, + missionId: UUID? = null, + modeId: Long? = null, + satelliteId: Long? = null + ) { + val normalizedIds = bookedSlotIds.distinct().filter { it > 0 } + if (normalizedIds.isEmpty()) { + return + } + val template = kafkaTemplate + if (template == null) { + logger.warn( + "Kafka booked slots publisher is unavailable, event skipped: status={}, bookedSlots={}, missionId={}, modeId={}", + status, + normalizedIds.size, + missionId, + modeId + ) + return + } + + val event = BookedSlotsStatusChangedEvent( + bookedSlotIds = normalizedIds, + status = status, + occurredAt = LocalDateTime.now(), + sourceService = applicationName, + missionId = missionId, + modeId = modeId, + satelliteId = satelliteId + ) + val message = KafkaMessage( + type = PcpKafkaEvent.BookedSlotsStatusChangedEvent, + data = event + ).apply { + source = applicationName + } + + val payload = try { + objectMapper.writeValueAsString(message) + } catch (exception: JsonProcessingException) { + throw IllegalStateException("Failed to serialize booked slots event", exception) + } + + val record = ProducerRecord(topic, missionId?.toString() ?: modeId?.toString() ?: normalizedIds.first().toString(), payload) + record.headers().add("type", PcpKafkaEvent.BookedSlotsStatusChangedEvent.name.toByteArray(StandardCharsets.UTF_8)) + template.send(record) + logger.info( + "Published Kafka-first booked slots status event: status={}, bookedSlots={}, missionId={}, modeId={}, satelliteId={}", + status, + normalizedIds.size, + missionId, + modeId, + satelliteId + ) + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ComplexMissionService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ComplexMissionService.kt new file mode 100644 index 0000000..69d31cb --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ComplexMissionService.kt @@ -0,0 +1,100 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +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 org.springframework.web.reactive.function.client.bodyToFlux +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime + +@Service +/** + * Stage 5 primary reader for persisted stage 4 survey modes. + * The source of truth is pcp-complex-mission-service, not slots-service. + */ +class ComplexMissionService(webClientBuilderProvider: ObjectProvider) { + + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${pcp.services.complex-mission-service:pcp-complex-mission-service}") + val url = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + fun activeSnapshot( + satelliteId: Long + ): SatelliteModeSnapshotResponseDTO? = + client() + .get() + .uri( + "/api/satellites/{satelliteId}/snapshots/active", + satelliteId + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono() + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(SatelliteModeSnapshotResponseDTO::class.java) + .blockOptional() + .orElse(null) + + fun snapshot(snapshotId: Long): SatelliteModeSnapshotResponseDTO = + client() + .get() + .uri("/api/satellites/snapshots/{snapshotId}", snapshotId) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono() + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(SatelliteModeSnapshotResponseDTO::class.java) + .block() + ?: throw CustomErrorException("Snapshot $snapshotId not found") + + fun snapshotModes( + snapshotId: Long, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime + ): List = + client() + .get() + .uri( + "/api/satellites/snapshots/{snapshotId}/modes?intervalStart={intervalStart}&intervalEnd={intervalEnd}", + snapshotId, + intervalStart, + intervalEnd + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono() + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux() + .collectList() + .block() + .orEmpty() + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt new file mode 100644 index 0000000..ea8df99 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt @@ -0,0 +1,135 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + + +import io.camunda.client.annotation.JobWorker +import io.camunda.client.CamundaClient +import io.camunda.client.api.response.ActivatedJob +import io.camunda.client.api.worker.JobClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO +import java.time.LocalDateTime +import java.util.UUID + + +@Service +class FlowService( + private val camundaClient: CamundaClient, + private val missionPlaningService: MissionPlaningService +) { + + private val logger : Logger = LoggerFactory.getLogger(this.javaClass) + + + + + @JobWorker(type = "calculateSatelliteSurveyMissions", autoComplete = false) + fun surveyPlan(client: JobClient, job: ActivatedJob) { + try { + val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString() + logger.info("Запрос на формирование плана съемки ${id}") + + val mission = missionPlaningService.add(MissionRequestDTO( + missionId = UUID.fromString(id), + satelliteId = job.variablesAsMap.getOrDefault("spacecraftId", 0).toString().toLong(), + missionStart = job.variablesAsMap["planStartTime"] + ?.toString() + ?.let(LocalDateTime::parse), + missionStop = job.variablesAsMap["planEndTime"] + ?.toString() + ?.let(LocalDateTime::parse) + + )) + + + missionPlaningService.loadSlots(mission.missionId) + val cnt = missionPlaningService.modesByMission(UUID.fromString(id)).size + + client.newCompleteCommand(job.key) + .variable("modes", cnt) + .send() + .exceptionally { + ex -> logger.warn(ex.message) + throw ex + } + } + catch (ex : Exception){ + logger.warn(ex.message) + } + } + + @JobWorker(type = "calculateSatelliteDropMissions", autoComplete = false) + fun surveyDrop(client: JobClient, job: ActivatedJob) { + try { + val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString() + logger.info("Запрос на формирование плана сбросов ${id}") + + missionPlaningService.loadSlots(UUID.fromString(id)) + val cnt = missionPlaningService.modesByMission(UUID.fromString(id)).size + + client.newCompleteCommand(job.key) + .variable("modes", cnt) + .send() + .exceptionally { + ex -> logger.warn(ex.message) + throw ex + } + } + catch (ex : Exception){ + logger.warn(ex.message) + } + } + + @JobWorker(type = "sendSatellitePlan", autoComplete = false) + fun sendPlan(client: JobClient, job: ActivatedJob) { + try { + val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString() + logger.info("Отправлен план ${id}") + + client.newCompleteCommand(job.key) + .send() + .exceptionally { + ex -> logger.warn(ex.message) + throw ex + } + + Thread.sleep(5000) + + logger.info("Иммитация сообщения о получении подтверждения $id") + camundaClient + .newPublishMessageCommand() + .messageName("Message_SatellitePlanAccepted") + .correlationKey(id) + .send() + .join() + } + catch (ex : Exception){ + logger.warn(ex.message) + } + } + + + @JobWorker(type = "updateSlotsStatus", autoComplete = false) + fun confirmPlan(client: JobClient, job: ActivatedJob) { + try { + + logger.info("Утверждение поана") + val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString() + + val ids = missionPlaningService.confirmMission(UUID.fromString(id)) + logger.info("План утвержден ${id}") + client.newCompleteCommand(job.key) + .variable("slots", ids) + .send() + .exceptionally { + ex -> logger.warn(ex.message) + throw ex + } + } + catch (ex : Exception){ + logger.warn(ex.message) + } + } + +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt new file mode 100644 index 0000000..c27c6a2 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -0,0 +1,781 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import ballistics.flightLine.PointOnEarthCalculator +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.orbitalPoints.timeStepper.RungeStepper +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.types.Orientation +import ballistics.types.THBLPoint +import ballistics.types.WorkCSType +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.io.WKTWriter +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.UUID +import kotlin.math.PI +import kotlin.math.abs + + +@Service +class MissionPlaningService( + private val missionRepository: MissionRepository, + private val modeRepository: ModeRepository, + private val planRepository: PlanRepository, + private val surveyModeRepository: SurveyModeRepository, + private val dropModeRepository: DropModeRepository, + private val dropModeSurveyRepository: DropModeSurveyRepository, + private val modeBookingRepository: ModeBookingRepository, + private val bookedSlotsEventPublisher: BookedSlotsEventPublisher, + private val ballisticsService: BallisticsService, + private val complexMissionService: ComplexMissionService, + private val satelliteCatalogClient: SatelliteCatalogClient, + private val satelliteMissionTypeFactory: SatelliteMissionTypeFactory, + private val logger: Logger = LoggerFactory.getLogger(MissionPlaningService::class.java) +) { + + fun all(): List = missionRepository.findAll().map { it.toResponse() } + + fun byId(id: UUID): MissionResponseDTO = + missionRepository.findById(id) + .orElseThrow { notFound(id) } + .toResponse() + + @Transactional + fun add(body: MissionRequestDTO): MissionResponseDTO { + logger.info("сохранить миссию ${body.missionId} ${body.missionStart} ${body.missionStop}") + val mission = missionRepository.save( + MissionEntity( + missionId = body.missionId?: UUID.randomUUID(), + satelliteId = body.satelliteId, + station = body.station, + missionStart = body.missionStart, + missionStop = body.missionStop, + status = normalizeStatus(body.status) + ) + ) + syncMainPlanWithMission(mission) + return mission.toResponse() + } + + @Transactional + fun update(id: UUID, body: MissionRequestDTO): MissionResponseDTO { + val mission = missionRepository.findById(id).orElseThrow { notFound(id) } + mission.satelliteId = body.satelliteId + mission.station = body.station + mission.missionStart = body.missionStart + mission.missionStop = body.missionStop + mission.status = normalizeStatus(body.status) + val savedMission = missionRepository.save(mission) + syncMainPlanWithMission(savedMission) + return savedMission.toResponse() + } + + fun delete(id: UUID) { + if (!missionRepository.existsById(id)) { + throw notFound(id) + } + missionRepository.deleteById(id) + } + + fun modesByMission(id: UUID): List { + if (!missionRepository.existsById(id)) { + throw notFound(id) + } + + val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(id) + return mapModesToResponse(modes) + } + + fun modesBySatelliteAndInterval( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List { + if (timeStop.isBefore(timeStart)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "timeStop must be greater than or equal to timeStart" + ) + } + + val modes = modeRepository + .findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( + satelliteId, + timeStop.atOffset(ZoneOffset.UTC) + ) + .filter { it.intersects(timeStart, timeStop) } + + return mapModesToResponse(modes) + } + + private fun MissionEntity.toResponse() = MissionResponseDTO( + missionId = missionId, + satelliteId = satelliteId, + station = station, + missionStart = missionStart, + missionStop = missionStop, + status = status + ) + + private fun normalizeStatus(status: String?): String = status?.trim().takeUnless { it.isNullOrEmpty() } ?: "NEW" + + private fun notFound(id: UUID) = + ResponseStatusException(HttpStatus.NOT_FOUND, "Mission with id $id not found") + + private fun mapModesToResponse(modes: List): List { + val bookingsByModeId = bookingsByModeId(modes) + return modes.map { it.toModeResponse(bookingsByModeId) } + } + + private fun bookingsByModeId(modes: List): Map> { + val surveyModeIds = modes.filterIsInstance().mapNotNull { it.id } + if (surveyModeIds.isEmpty()) { + return emptyMap() + } + + return modeBookingRepository.findAllByMode_IdIn(surveyModeIds) + .groupBy( + keySelector = { it.mode?.id }, + valueTransform = { it.slotId } + ) + .mapKeys { (modeId, _) -> modeId ?: -1L } + .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } + } + + private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { + val modeStart = timeStart?.toLocalDateTime() ?: return false + val modeStop = modeStop() ?: return false + return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop) + } + + private fun ModeEntity.modeStop(): LocalDateTime? { + val modeStart = timeStart?.toLocalDateTime() ?: return null + val durationSeconds = when (this) { + is SurveyModeEntity -> duration + is DropModeEntity -> duration + else -> 0.0 + }.coerceAtLeast(0.0) + + return modeStart.plusNanos((durationSeconds * 1_000_000_000L).toLong()) + } + + private fun syncMainPlanWithMission(mission: MissionEntity) { + val plan = planRepository.findFirstByMissionMissionIdOrderByIdAsc(mission.missionId) + .orElseGet { PlanEntity(mission = mission, planNumber = 1) } + + plan.mission = mission + plan.planStart = mission.missionStart + plan.planStop = mission.missionStop + plan.planNumber = 1 + + planRepository.save(plan) + } + + private fun ModeEntity.toModeResponse(bookingsByModeId: Map> = emptyMap()): ModeResponseDTO { + val modeId = id ?: throw IllegalStateException("Mode id is null") + return when (this) { + is SurveyModeEntity -> toSurveyResponse(bookingsByModeId[modeId].orEmpty()) + + is DropModeEntity -> DropModeResponseDTO( + id = modeId, + planId = plan?.id ?: throw IllegalStateException("Plan id is null for mode $modeId"), + timeStart = timeStart, + revolution = revolution, + station = station, + duration = duration, + surveys = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(modeId) + .map { link -> + val survey = link.surveyMode + ?: throw IllegalStateException("Survey mode is null for drop mode $modeId") + survey.id?:-1 + } + ) + else -> throw IllegalStateException("Unsupported mode type: ${this::class.qualifiedName}") + } + } + + private fun SurveyModeEntity.toSurveyResponse(bookedSlotIds: List = emptyList()): SurveyModeResponseDTO { + val surveyId = id ?: throw IllegalStateException("Survey mode id is null") + val surveyPlanId = plan?.id ?: throw IllegalStateException("Plan id is null for survey mode $surveyId") + + return SurveyModeResponseDTO( + id = surveyId, + planId = surveyPlanId, + timeStart = timeStart, + revolution = revolution, + status = status.name, + satelliteModeId = satelliteModeId, + source = source.name, + lat = lat, + longitude = long, + duration = duration, + contourWkt = contourWkt, + roll = roll, + bookedSlotIds = bookedSlotIds + ) + } + + + + + @Transactional + fun loadSlots( + missionId: UUID, + overrideSatelliteId: Long? = null, + overrideIntervalStart: LocalDateTime? = null, + overrideIntervalEnd: LocalDateTime? = null, + comPlanSnapshotId: Long? = null + ): MissionSurveyCalculationResponseDTO { + val startedAt = LocalDateTime.now() + logger.info( + "Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}", + missionId, + comPlanSnapshotId + ) + val mission = missionRepository.findById(missionId).orElseThrow { + notFound(missionId) + } + if (overrideSatelliteId != null || overrideIntervalStart != null || overrideIntervalEnd != null) { + overrideSatelliteId?.let { mission.satelliteId = it } + overrideIntervalStart?.let { mission.missionStart = it } + overrideIntervalEnd?.let { mission.missionStop = it } + missionRepository.save(mission) + } + + val satelliteId = overrideSatelliteId ?: mission.satelliteId + val intervalStart = overrideIntervalStart ?: mission.missionStart + ?: throw CustomErrorException("Не задано время начала миссии") + val intervalEnd = overrideIntervalEnd ?: mission.missionStop + ?: throw CustomErrorException("Не задано время конца миссии") + + if (intervalEnd.isBefore(intervalStart)) { + throw CustomErrorException("Некорректный интервал миссии: missionStop < missionStart") + } + + val satellite = satelliteCatalogClient.getSatellite(satelliteId) + val missionType = satelliteMissionTypeFactory.getBySatellite(satellite) + logger.info( + "Расчет плана съемки для миссии {} выполняется по логике типа спутника: satelliteId={}, typeCode={}, missionType={}", + missionId, + satelliteId, + satellite.typeCode, + missionType.typeCode + ) + + val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) + .orElseGet { + planRepository.save( + PlanEntity( + mission = mission, + planStart = mission.missionStart, + planStop = mission.missionStop, + planNumber = 1 + ) + ) + } + plan.planStart = intervalStart + plan.planStop = intervalEnd + + val planId = plan.id ?: throw IllegalStateException("Не найден план для миссии ${mission.missionId}") + + val snapshotSelection = resolveStage4Snapshot( + satelliteId = satelliteId, + intervalStart = intervalStart, + intervalEnd = intervalEnd, + explicitSnapshotId = comPlanSnapshotId + ) + plan.stage4SnapshotId = snapshotSelection.snapshot.id + planRepository.save(plan) + + val survs = complexMissionService.snapshotModes( + snapshotId = snapshotSelection.snapshot.id, + intervalStart = intervalStart, + intervalEnd = intervalEnd + ) + + logger.info( + "Этап 5 загружает persisted snapshot этапа 4 из pcp-complex-mission-service: missionId={}, satelliteId={}, interval=[{} - {}], selection={}, snapshotId={}, loadedModes={}, manualModes={}, modesWithBookedSlots={}", + missionId, + satelliteId, + intervalStart, + intervalEnd, + snapshotSelection.selectionMode, + snapshotSelection.snapshot.id, + survs.size, + survs.count { it.source == SurveyModeSource.MANUAL.name }, + survs.count { it.bookedSlotIds.isNotEmpty() } + ) + + modeRepository.deleteSurveyModesByPlanId(planId) + + if (survs.isEmpty()) { + val finishedAt = LocalDateTime.now() + return MissionSurveyCalculationResponseDTO( + missionId = missionId, + completed = true, + startedAt = startedAt, + finishedAt = finishedAt, + durationMs = Duration.between(startedAt, finishedAt).toMillis(), + comPlanSnapshotId = snapshotSelection.snapshot.id, + surveyModesCount = 0, + manualModesCount = 0, + complanModesCount = 0, + slotsModesCount = 0, + mixedModesCount = 0, + bookedSlotIdsCount = 0 + ) + } + + val preparedModes = survs.map { surv -> + missionType.prepareSurvey(satellite, surv, plan) to surv.bookedSlotIds.distinct() + } + + val savedModes = surveyModeRepository.saveAll( + (preparedModes.mapNotNull { it.first }) + ).toList() + + val modeBookings = savedModes.zip(preparedModes.map { it.second }) + .flatMap { (mode, slotIds) -> + slotIds + .filter { it > 0 } + .map { slotId -> ModeBookingEntity(mode = mode, slotId = slotId) } + } + + if (modeBookings.isNotEmpty()) { + modeBookingRepository.saveAll(modeBookings) + } + + logger.info( + "Расчет плана съемки stage 5 для миссии {} завершен по persisted snapshot stage 4: selection={}, snapshotId={}, createdSurveyModes={}, preservedBookings={}", + missionId, + snapshotSelection.selectionMode, + snapshotSelection.snapshot.id, + savedModes.size, + modeBookings.size + ) + + val finishedAt = LocalDateTime.now() + return MissionSurveyCalculationResponseDTO( + missionId = missionId, + completed = true, + startedAt = startedAt, + finishedAt = finishedAt, + durationMs = Duration.between(startedAt, finishedAt).toMillis(), + comPlanSnapshotId = snapshotSelection.snapshot.id, + surveyModesCount = savedModes.size, + manualModesCount = survs.count { it.source == SurveyModeSource.MANUAL.name }, + complanModesCount = survs.count { it.source == SurveyModeSource.COMPLAN.name }, + slotsModesCount = survs.count { it.source == SurveyModeSource.SLOTS.name }, + mixedModesCount = survs.count { it.source == SurveyModeSource.MIXED.name }, + bookedSlotIdsCount = survs.flatMap { it.bookedSlotIds }.filter { it > 0 }.distinct().size + ) + } + + private data class Stage4SnapshotSelection( + val snapshot: space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO, + val selectionMode: String + ) + + private fun resolveStage4Snapshot( + satelliteId: Long, + intervalStart: LocalDateTime, + intervalEnd: LocalDateTime, + explicitSnapshotId: Long? + ): Stage4SnapshotSelection { + val snapshot = if (explicitSnapshotId != null) { + val explicitSnapshot = complexMissionService.snapshot(explicitSnapshotId) + logger.info( + "Stage 5 uses explicitly provided stage4 snapshot: satelliteId={}, interval=[{} - {}], snapshotId={}", + satelliteId, + intervalStart, + intervalEnd, + explicitSnapshotId + ) + explicitSnapshot + } else { + val activeSnapshot = complexMissionService.activeSnapshot(satelliteId) + ?: throw CustomErrorException( + "Active stage4 snapshot not found for satelliteId=$satelliteId and interval=[$intervalStart - $intervalEnd]" + ) + logger.info( + "Stage 5 auto-selected active stage4 snapshot by satelliteId and will filter modes by mission interval: satelliteId={}, interval=[{} - {}], snapshotId={}", + satelliteId, + intervalStart, + intervalEnd, + activeSnapshot.id + ) + activeSnapshot + } + + if (snapshot.satelliteId != satelliteId) { + throw CustomErrorException( + "Snapshot ${snapshot.id} does not match requested satelliteId=$satelliteId: " + + "snapshot.satelliteId=${snapshot.satelliteId}" + ) + } + + return Stage4SnapshotSelection( + snapshot = snapshot, + selectionMode = if (explicitSnapshotId != null) "explicit" else "auto-active" + ) + } + + private fun extractCenterLatLon(contourWkt: String?): Pair { + if (contourWkt.isNullOrBlank()) { + return 0.0 to 0.0 + } + + val pairs = Regex("(-?\\d+(?:\\.\\d+)?)\\s+(-?\\d+(?:\\.\\d+)?)") + .findAll(contourWkt) + .mapNotNull { match -> + val lon = match.groupValues[1].toDoubleOrNull() + val lat = match.groupValues[2].toDoubleOrNull() + if (lon == null || lat == null) null else lon to lat + } + .toMutableList() + + if (pairs.isEmpty()) { + return 0.0 to 0.0 + } + + if (pairs.size > 1 && pairs.first() == pairs.last()) { + pairs.removeAt(pairs.lastIndex) + } + + val avgLon = pairs.map { it.first }.average() + val avgLat = pairs.map { it.second }.average() + return avgLat to avgLon + } + + + fun prepareSurvey(surv: SatelliteModeResponseDTO, plan: PlanEntity): SurveyModeEntity = + satelliteMissionTypeFactory.defaultType().prepareSurvey( + satellite = space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO(), + surv = surv, + plan = plan + ) + + + private fun contour( + stepper: AbstractStepper, + tn: LocalDateTime, + tk: LocalDateTime, + roll: Double, + capture: Double + ): String { + var t = tn + val points = mutableListOf() + while (t <= tk) { + points.add( + stepper.calculate(fromDateTime(t)) + ?: throw CustomErrorException("Ошибка расчета выхода на заданное время") + ) + t = t.plusSeconds(5) + } + val r = points.map { + viewParams(it, roll + capture) ?: THBLPoint( + 0.0, + 0.0, + 0.0, + 0.0 + ) + } + val l = points.map { + viewParams(it, roll - capture) ?: THBLPoint( + 0.0, + 0.0, + 0.0, + 0.0 + ) + } + val geom = mutableListOf() + for (rp in r) + geom.add(Coordinate(rp.long * 180 / PI, rp.lat * 180 / PI, 0.0)) + for (lp in l.reversed()) + geom.add(Coordinate(lp.long * 180 / PI, lp.lat * 180 / PI, 0.0)) + if (!geom.isEmpty()) + geom.add(Coordinate(geom.first().x, geom.first().y)) + val geometryFactory = GeometryFactory() + val shell = geometryFactory.createLinearRing(geom.toTypedArray()) + val polygon = geometryFactory.createPolygon(shell) + val wktWriter = WKTWriter() + return wktWriter.write(polygon) + } + + + private fun viewParams(pos: OrbitalPoint, gamma: Double): THBLPoint? { + val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + return calculator.pointOnEarth(pos, Orientation(0.0, gamma * PI / 180.0, 0.0)) + } + + + @Transactional + fun calculateDrops(missionId : UUID){ + + logger.info("Расчет плана сбросов для миссии $missionId") + + val mission = missionRepository.findById(missionId).orElseThrow { + notFound(missionId) + } + val satellite = satelliteCatalogClient.getSatellite(mission.satelliteId) + val missionType = satelliteMissionTypeFactory.getBySatellite(satellite) + logger.info( + "Расчет плана сбросов для миссии {} выполняется по логике типа спутника: satelliteId={}, typeCode={}, missionType={}", + missionId, + mission.satelliteId, + satellite.typeCode, + missionType.typeCode + ) + val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1) + .orElseGet { + planRepository.save( + PlanEntity( + mission = mission, + planStart = mission.missionStart, + planStop = mission.missionStop, + planNumber = 1 + ) + ) + } + + val planId = plan.id ?: throw IllegalStateException("Plan id is null for mission $missionId") + modeRepository.deleteDropModesByPlanId(planId) + + val surveys = surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId) + + logger.info("Для миссии $missionId загруженно ${surveys.size} съемок для расчета плана сбросов") + + if (surveys.isEmpty()) { + return + } + + val zones = ballisticsService.rva(mission.satelliteId, + mission.missionStart?: throw CustomErrorException("Не задано время начала миссии"), + mission.missionStop?: throw CustomErrorException("Не задано время конца миссии")) + .sortedBy { it.onStart.time } + .toList() + if (zones.isEmpty()) { + logger.warn("Для миссии $missionId нет ЗРВ") + return + } + logger.info("Для миссии $missionId полученно ${zones.size} ЗРВ") + + val unassigned = surveys.toMutableList() + zones.forEach { zone -> + val zoneStart = zone.onStart.time + val zoneDuration = Duration.between(zone.onStart.time, zone.onStop.time).toMillis().toDouble() / 1000.0 + if (zoneDuration <= 0.0) { + return@forEach + } + + val selectedSurveys = mutableListOf() + var usedDuration = 0.0 + + val iterator = unassigned.iterator() + while (iterator.hasNext()) { + val survey = iterator.next() + val surveyStartOffset = survey.timeStart + if (surveyStartOffset == null) { + iterator.remove() + continue + } + val surveyStart = surveyStartOffset.toLocalDateTime() + + val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) + if (!surveyEnd.isBefore(zoneStart)) { + continue + } + + val dropDuration = missionType.calculateDropDuration(satellite, survey).coerceAtLeast(0.0) + if (usedDuration + dropDuration > zoneDuration) { + continue + } + + usedDuration += dropDuration + selectedSurveys.add(survey) + iterator.remove() + } + + if (selectedSurveys.isEmpty()) { + return@forEach + } + + val dropMode = dropModeRepository.save( + DropModeEntity( + plan = plan, + timeStart = zoneStart.atOffset(ZoneOffset.UTC), + revolution = zone.revolution, + station = zone.stationId.toString(), + duration = usedDuration + ) + ) + + val links = selectedSurveys.map { survey -> + DropModeSurveyEntity( + dropMode = dropMode, + surveyMode = survey + ) + } + dropModeSurveyRepository.saveAll(links) + } + logger.info("План сбросов для миссии $missionId расчитан") + + } + + fun calculateDropDuration(surv : SurveyModeEntity) : Double = + satelliteMissionTypeFactory.defaultType().calculateDropDuration( + satellite = space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO(), + survey = surv + ) + + + @Transactional + fun confirmMission(missionId: UUID) : List{ + val mission = missionRepository.findById(missionId).orElseThrow { + notFound(missionId) + } + + mission.status = "CONFIRMED" + missionRepository.save(mission) + + val surveyModeIds = surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId) + .map { it.id?:-1 } + .distinct() + + if (surveyModeIds.isEmpty()) { + logger.info("Для миссии $missionId нет съемок") + return listOf() + } + + val bookedSlotIds = modeBookingRepository.findAllByMode_IdIn(surveyModeIds) + .map { it.slotId } + .filter { it > 0 } + .distinct() + + if (bookedSlotIds.isEmpty()) { + logger.info("Для миссии $missionId нет забронированных слотов для подтверждения") + return listOf() + } + + bookedSlotsEventPublisher.publish( + bookedSlotIds = bookedSlotIds, + status = BookedSlotStatus.PROCESSED, + missionId = missionId, + satelliteId = mission.satelliteId + ) + logger.info("Для миссии $missionId опубликовано событие PROCESSED по {} слотам", bookedSlotIds.size) + return bookedSlotIds + } + + @Transactional + fun updateSurveyModeStatus(modeId: Long, rawStatus: SurveyModeStatus) { + + val surveyMode = surveyModeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found") + } + + surveyMode.status = rawStatus + surveyModeRepository.save(surveyMode) + + val bookedSlotIds = modeBookingRepository.findAllByMode_IdIn(listOf(modeId)) + .map { it.slotId } + .filter { it > 0 } + .distinct() + + if (bookedSlotIds.isEmpty()) { + logger.info("Survey mode {} updated to {} without booked slots", modeId, rawStatus) + return + } + + bookedSlotsEventPublisher.publish( + bookedSlotIds = bookedSlotIds, + status = rawStatus.toBookedSlotStatus(), + missionId = surveyMode.plan?.mission?.missionId, + modeId = modeId, + satelliteId = surveyMode.plan?.mission?.satelliteId + ) + logger.info( + "Survey mode {} updated to {}, published booked slot event for {} slots", + modeId, + rawStatus, + bookedSlotIds.size + ) + } + + @Transactional(readOnly = true) + fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? { + val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1) + val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1) + val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( + satelliteNumber = routePassport.kaShort.trim(), + revolution = routePassport.orbitNumber, + timeStartFrom = searchStart, + timeStartTo = searchEnd + ) + + return candidates.minByOrNull { candidate -> + val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE + abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis()) + } + } + + private fun normalizeSurveyModeStatus(value: String): SurveyModeStatus = + runCatching { SurveyModeStatus.valueOf(value.trim().uppercase()) } + .getOrElse { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported survey mode status: $value" + ) + } + + private fun SurveyModeStatus.toBookedSlotStatus(): BookedSlotStatus = when (this) { + SurveyModeStatus.PROCESSED -> BookedSlotStatus.FINISHED + SurveyModeStatus.CANCELED -> BookedSlotStatus.CANCELED + SurveyModeStatus.FAILED -> BookedSlotStatus.FAILED + SurveyModeStatus.PLANNED, SurveyModeStatus.DROPPED -> throw IllegalStateException("$this survey mode status is not publishable") + } + +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ModeStatusChangedKafkaListener.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ModeStatusChangedKafkaListener.kt new file mode 100644 index 0000000..b149367 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ModeStatusChangedKafkaListener.kt @@ -0,0 +1,73 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class ModeStatusChangedKafkaListener( + objectMapperProvider: ObjectProvider, + private val missionPlaningService: MissionPlaningService, +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + topics = ["\${app.kafka.topics.mode-status-changed:pcp.route.in.v1}"], + groupId = "\${spring.kafka.consumer.group-id}-mode-status-changed" + ) + fun consume(message: String) { + try { + val kafkaMessage = objectMapper.readValue( + message, + object : TypeReference>() {}, + ) + if (kafkaMessage.type != PcpKafkaEvent.ModeStatusChangedEvent) { + logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type) + return + } + + val routePassport = kafkaMessage.data + val mode = missionPlaningService.findMode(routePassport) + if (mode?.id == null) { + logger.error( + "Survey mode not found for ModeStatusChangedEvent: routeId={}, satelliteNumber={}, revolution={}, intervalBegin={}, routeStatus={}, traceId={}, source={}", + routePassport.routeId, + routePassport.kaShort, + routePassport.orbitNumber, + routePassport.intervalBegin, + routePassport.routeStatus, + kafkaMessage.traceId, + kafkaMessage.source, + ) + return + } + + missionPlaningService.updateSurveyModeStatus(mode.id, routePassport.routeStatus) + logger.info( + "Mode status changed event processed: routeId={}, surveyModeId={}, satelliteNumber={}, revolution={}, intervalBegin={}, routeStatus={}, traceId={}, source={}", + routePassport.routeId, + mode.id, + routePassport.kaShort, + routePassport.orbitNumber, + routePassport.intervalBegin, + routePassport.routeStatus, + kafkaMessage.traceId, + kafkaMessage.source, + ) + } catch (exception: Exception) { + logger.error("Failed to process ModeStatusChangedEvent Kafka payload", exception) + throw exception + } + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedKafkaListener.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedKafkaListener.kt new file mode 100644 index 0000000..1c65edf --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedKafkaListener.kt @@ -0,0 +1,72 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent + +private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-mission-planing-service-satellite-deleted" + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class SatelliteDeletedKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteDeletedService: SatelliteDeletedService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + topics = ["\${app.kafka.topics.satellites:pcp.satellites}"], + groupId = SATELLITE_DELETED_CONSUMER_GROUP + ) + fun consume(message: String) { + try { + val kafkaMessage = objectMapper.readValue( + message, + object : TypeReference>() {} + ) + if (kafkaMessage.type != PcpKafkaEvent.SatelliteDeletedEvent) { + logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type) + return + } + + val event = kafkaMessage.data + logger.info( + "Received SatelliteDeletedEvent in mission-planing-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + event.deleteIdentifiers().forEach { satelliteId -> + satelliteDeletedService.deleteSatelliteData(satelliteId) + } + logger.info( + "SatelliteDeletedEvent processed in mission-planing-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + } catch (exception: Exception) { + logger.error("Failed to process SatelliteDeletedEvent in mission-planing-service", exception) + throw exception + } + } + + private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List = + listOfNotNull(satelliteId, noradId).distinct() +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedService.kt new file mode 100644 index 0000000..9798d64 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedService.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository + +@Service +class SatelliteDeletedService( + private val missionRepository: MissionRepository, + private val planRepository: PlanRepository, + private val modeRepository: ModeRepository +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun deleteSatelliteData(satelliteId: Long): SatelliteMissionPlanningDeletionSummary { + val missionsBeforeDelete = missionRepository.countBySatelliteId(satelliteId) + val plansDeletedByCascade = planRepository.countByMissionSatelliteId(satelliteId) + val modesDeletedByCascade = modeRepository.countByPlan_Mission_SatelliteId(satelliteId) + val missionsDeleted = missionRepository.deleteAllBySatelliteId(satelliteId) + val summary = SatelliteMissionPlanningDeletionSummary( + satelliteId = satelliteId, + missionsDeleted = missionsDeleted, + plansDeletedByCascade = plansDeletedByCascade, + modesDeletedByCascade = modesDeletedByCascade + ) + logger.info( + "Deleted mission-planning data for satellite: satelliteId={}, missionsBeforeDelete={}, missionsDeleted={}, plansDeletedByCascade={}, modesDeletedByCascade={}", + summary.satelliteId, + missionsBeforeDelete, + summary.missionsDeleted, + summary.plansDeletedByCascade, + summary.modesDeletedByCascade + ) + return summary + } +} + +data class SatelliteMissionPlanningDeletionSummary( + val satelliteId: Long, + val missionsDeleted: Long, + val plansDeletedByCascade: Long, + val modesDeletedByCascade: Long +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/catalog/SatelliteCatalogClient.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/catalog/SatelliteCatalogClient.kt new file mode 100644 index 0000000..90bca74 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/catalog/SatelliteCatalogClient.kt @@ -0,0 +1,49 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.catalog + +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 org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import tools.jackson.databind.ObjectMapper + +@Service +class SatelliteCatalogClient(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}") + private val url: String = "" + + private fun client() = webClientBuilder.baseUrl(url).build() + + fun getSatellite(satelliteId: Long): SatelliteDTO = + client() + .get() + .uri("/api/satellites/{satelliteId}", satelliteId) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono() + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(SatelliteDTO::class.java) + .block() + ?: throw CustomErrorException("Satellite $satelliteId not found in satellite-catalog-service") + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/DefaultSatelliteMissionType.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/DefaultSatelliteMissionType.kt new file mode 100644 index 0000000..ef208f4 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/DefaultSatelliteMissionType.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import org.springframework.stereotype.Component + +@Component +class DefaultSatelliteMissionType : LegacySatelliteMissionTypeSupport() { + override val typeCode: String = TYPE_CODE + + override fun supports(satelliteTypeCode: String?): Boolean = + satelliteTypeCode.isNullOrBlank() || satelliteTypeCode.trim().equals(TYPE_CODE, ignoreCase = true) + + companion object { + const val TYPE_CODE = "DEFAULT" + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/EmissioSatelliteMissionType.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/EmissioSatelliteMissionType.kt new file mode 100644 index 0000000..095c7b3 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/EmissioSatelliteMissionType.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import org.springframework.stereotype.Component + +/** + * Mission planning behavior for Emissio satellites. + * + * At the moment this class intentionally preserves the legacy behavior. + * Type-specific restrictions and formulas should be implemented here when the + * Emissio mission model is formalized. + */ +@Component +class EmissioSatelliteMissionType : LegacySatelliteMissionTypeSupport() { + override val typeCode: String = TYPE_CODE + + companion object { + const val TYPE_CODE = "EMISSIO" + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt new file mode 100644 index 0000000..4b02ce8 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import java.time.ZoneOffset + +abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { + override fun prepareSurvey( + satellite: SatelliteDTO, + surv: SatelliteModeResponseDTO, + plan: PlanEntity + ): SurveyModeEntity = SurveyModeEntity( + plan = plan, + timeStart = surv.startTime.atOffset(ZoneOffset.UTC), + revolution = surv.revolution, + lat = surv.lat, + long = surv.longitude, + duration = surv.duration, + contourWkt = surv.contourWkt, + roll = surv.roll, + satelliteModeId = surv.id, + status = SurveyModeStatus.PLANNED, + source = SurveyModeSource.valueOf(surv.source) + ) + + override fun calculateDropDuration( + satellite: SatelliteDTO, + survey: SurveyModeEntity + ): Double = survey.duration * DEFAULT_DROP_DURATION_MULTIPLIER + + companion object { + const val DEFAULT_DROP_DURATION_MULTIPLIER = 10.1 + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/ReflixioSatelliteMissionType.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/ReflixioSatelliteMissionType.kt new file mode 100644 index 0000000..0c1d918 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/ReflixioSatelliteMissionType.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import org.springframework.stereotype.Component + +/** + * Mission planning behavior for Reflixio satellites. + * + * At the moment this class intentionally preserves the legacy behavior. + * Type-specific restrictions and formulas should be implemented here when the + * Reflixio mission model is formalized. + */ +@Component +class ReflixioSatelliteMissionType : LegacySatelliteMissionTypeSupport() { + override val typeCode: String = TYPE_CODE + + companion object { + const val TYPE_CODE = "REFLIXIO" + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionType.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionType.kt new file mode 100644 index 0000000..8336cfc --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionType.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO + +/** + * Type-specific mission planning behavior for one satellite type. + * + * Implementations encapsulate all rules that may differ by satellite type: + * survey preparation, survey restrictions, drop duration formulas and future + * type-specific constraints. The default implementation must preserve current + * legacy mission planning behavior. + */ +interface SatelliteMissionType { + val typeCode: String + + fun supports(satelliteTypeCode: String?): Boolean = + satelliteTypeCode?.trim()?.equals(typeCode, ignoreCase = true) == true + + fun prepareSurvey( + satellite: SatelliteDTO, + surv: SatelliteModeResponseDTO, + plan: PlanEntity + ): SurveyModeEntity + + fun calculateDropDuration( + satellite: SatelliteDTO, + survey: SurveyModeEntity + ): Double +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionTypeFactory.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionTypeFactory.kt new file mode 100644 index 0000000..b879679 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionTypeFactory.kt @@ -0,0 +1,41 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO + +@Component +class SatelliteMissionTypeFactory( + private val missionTypes: List, + private val defaultMissionType: DefaultSatelliteMissionType +) { + private val logger = LoggerFactory.getLogger(SatelliteMissionTypeFactory::class.java) + + private val missionTypesByCode: Map = missionTypes + .associateBy { normalize(it.typeCode) } + + fun getBySatellite(satellite: SatelliteDTO): SatelliteMissionType = + getByTypeCode(satellite.typeCode) + + fun getByTypeCode(typeCode: String?): SatelliteMissionType { + val normalizedTypeCode = normalize(typeCode) + if (normalizedTypeCode.isBlank()) { + return defaultMissionType + } + + val missionType = missionTypesByCode[normalizedTypeCode] + if (missionType != null) { + return missionType + } + + logger.warn( + "Mission planner for satellite type '{}' was not found; default mission planner will be used", + typeCode + ) + return defaultMissionType + } + + fun defaultType(): SatelliteMissionType = defaultMissionType + + private fun normalize(value: String?): String = value?.trim()?.uppercase().orEmpty() +} diff --git a/services/pcp-mission-planing-service/src/main/resources/application.yaml b/services/pcp-mission-planing-service/src/main/resources/application.yaml new file mode 100644 index 0000000..33c1a3b --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-mission-planing-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V1__missions.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V1__missions.sql new file mode 100644 index 0000000..e67a730 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V1__missions.sql @@ -0,0 +1,12 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + + +CREATE TABLE IF NOT EXISTS missions ( + mission_id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + satellite_id BIGINT NOT NULL, + station VARCHAR(32), + mission_start TIMESTAMP WITH TIME ZONE, + mission_stop TIMESTAMP WITH TIME ZONE, + status VARCHAR(15) DEFAULT 'NEW' + ); +CREATE INDEX idx_missions_mission_id ON missions(mission_id); \ No newline at end of file diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V2__plans.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V2__plans.sql new file mode 100644 index 0000000..e36f81b --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V2__plans.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS plans ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + mission_id UUID NOT NULL REFERENCES missions(mission_id) ON DELETE CASCADE, + plan_start TIMESTAMP WITH TIME ZONE, + plan_stop TIMESTAMP WITH TIME ZONE, + plan_number BIGINT NOT NULL, + revolution BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_plans_mission_id ON plans(mission_id); +CREATE INDEX IF NOT EXISTS idx_plans_plan_number ON plans(plan_number); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V3__modes.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V3__modes.sql new file mode 100644 index 0000000..db7b005 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V3__modes.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS modes ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + plan_id BIGINT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, + time_start TIMESTAMP WITH TIME ZONE, + revolution BIGINT NOT NULL, + type VARCHAR(20) NOT NULL, + CONSTRAINT chk_modes_type CHECK (type IN ('SURVEY', 'DROP')) +); + +CREATE INDEX IF NOT EXISTS idx_modes_plan_id ON modes(plan_id); +CREATE INDEX IF NOT EXISTS idx_modes_type ON modes(type); + +CREATE TABLE IF NOT EXISTS survey_modes ( + id BIGINT PRIMARY KEY REFERENCES modes(id) ON DELETE CASCADE, + lat DOUBLE PRECISION NOT NULL, + long DOUBLE PRECISION NOT NULL, + duration DOUBLE PRECISION NOT NULL, + contour_wkt TEXT, + contour_geometry geometry(Polygon, 4326) + GENERATED ALWAYS AS (ST_GeomFromText(contour_wkt)::geometry(Polygon, 4326)) STORED, + roll DOUBLE PRECISION NOT NULL +); + +CREATE TABLE IF NOT EXISTS drop_modes ( + id BIGINT PRIMARY KEY REFERENCES modes(id) ON DELETE CASCADE, + station VARCHAR(255), + duration DOUBLE PRECISION NOT NULL +); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V4__survey_source_and_mode_booking.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V4__survey_source_and_mode_booking.sql new file mode 100644 index 0000000..8127bb2 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V4__survey_source_and_mode_booking.sql @@ -0,0 +1,15 @@ +ALTER TABLE survey_modes + ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'USER'; + +ALTER TABLE survey_modes + DROP CONSTRAINT IF EXISTS chk_survey_modes_source; + +CREATE TABLE IF NOT EXISTS mode_booking ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + mode_id BIGINT NOT NULL REFERENCES survey_modes(id) ON DELETE CASCADE, + slot_id BIGINT NOT NULL, + CONSTRAINT uq_mode_booking_mode_slot UNIQUE(mode_id, slot_id) +); + +CREATE INDEX IF NOT EXISTS idx_mode_booking_mode_id ON mode_booking(mode_id); +CREATE INDEX IF NOT EXISTS idx_mode_booking_slot_id ON mode_booking(slot_id); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V5__drop_mode_survey_link.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V5__drop_mode_survey_link.sql new file mode 100644 index 0000000..3d720ec --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V5__drop_mode_survey_link.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS drop_mode_survey ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + drop_mode_id BIGINT NOT NULL REFERENCES drop_modes(id) ON DELETE CASCADE, + survey_mode_id BIGINT NOT NULL REFERENCES survey_modes(id) ON DELETE CASCADE, + CONSTRAINT uq_drop_mode_survey UNIQUE(drop_mode_id, survey_mode_id) +); + +CREATE INDEX IF NOT EXISTS idx_drop_mode_survey_drop_id ON drop_mode_survey(drop_mode_id); +CREATE INDEX IF NOT EXISTS idx_drop_mode_survey_survey_id ON drop_mode_survey(survey_mode_id); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V6__survey_mode_stage4_trace.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V6__survey_mode_stage4_trace.sql new file mode 100644 index 0000000..49ae0fe --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V6__survey_mode_stage4_trace.sql @@ -0,0 +1,5 @@ +ALTER TABLE survey_modes + ADD COLUMN IF NOT EXISTS satellite_mode_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_survey_modes_satellite_mode_id + ON survey_modes(satellite_mode_id); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V7__survey_mode_stage4_trace_backfill.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V7__survey_mode_stage4_trace_backfill.sql new file mode 100644 index 0000000..49ae0fe --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V7__survey_mode_stage4_trace_backfill.sql @@ -0,0 +1,5 @@ +ALTER TABLE survey_modes + ADD COLUMN IF NOT EXISTS satellite_mode_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_survey_modes_satellite_mode_id + ON survey_modes(satellite_mode_id); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V8__survey_mode_status.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V8__survey_mode_status.sql new file mode 100644 index 0000000..afb9dd5 --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V8__survey_mode_status.sql @@ -0,0 +1,9 @@ +ALTER TABLE survey_modes + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED'; + +ALTER TABLE survey_modes + DROP CONSTRAINT IF EXISTS chk_survey_modes_status; + +ALTER TABLE survey_modes + ADD CONSTRAINT chk_survey_modes_status + CHECK (status IN ('PLANNED', 'FINISHED', 'CANCELED', 'FAILED')); diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V9__plan_stage4_snapshot.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V9__plan_stage4_snapshot.sql new file mode 100644 index 0000000..1cc806d --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V9__plan_stage4_snapshot.sql @@ -0,0 +1,5 @@ +ALTER TABLE plans + ADD COLUMN IF NOT EXISTS stage4_snapshot_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_plans_stage4_snapshot_id + ON plans(stage4_snapshot_id); diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpBallisticsServiceApplicationTests.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpBallisticsServiceApplicationTests.kt new file mode 100644 index 0000000..3a022c5 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpBallisticsServiceApplicationTests.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp.pcp_mission_planing_service + +import io.camunda.client.CamundaClient +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.mockito.Mockito.mock + +@SpringBootTest +@Import(PcpBallisticsServiceApplicationTests.TestBeans::class) +class PcpBallisticsServiceApplicationTests { + + @Test + fun contextLoads() { + } + + @TestConfiguration + class TestBeans { + @Bean + fun camundaClient(): CamundaClient = mock(CamundaClient::class.java) + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionControllerTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionControllerTest.kt new file mode 100644 index 0000000..ee4f88e --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionControllerTest.kt @@ -0,0 +1,58 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals + +class MissionControllerTest { + + private val missionPlaningService = mock(MissionPlaningService::class.java) + private val controller = MissionController(missionPlaningService) + + @Test + fun `calculate surveys endpoint accepts satellite and interval overrides`() { + val missionId = UUID.randomUUID() + val startedAt = LocalDateTime.of(2026, 4, 2, 10, 0) + val responseBody = MissionSurveyCalculationResponseDTO( + missionId = missionId, + completed = true, + startedAt = startedAt, + finishedAt = startedAt.plusSeconds(2), + durationMs = 2_000, + comPlanSnapshotId = 99L, + surveyModesCount = 3, + manualModesCount = 1, + complanModesCount = 1, + slotsModesCount = 1, + mixedModesCount = 0, + bookedSlotIdsCount = 4 + ) + doReturn(responseBody).`when`(missionPlaningService).loadSlots(missionId, comPlanSnapshotId = 99L) + + val response = controller.calculateSurveys( + id = missionId, + comPlanSnapshotId = 99L + ) + + assertEquals(responseBody, response) + verify(missionPlaningService).loadSlots(missionId, comPlanSnapshotId = 99L) + } + + @Test + fun `update survey mode status endpoint delegates to service`() { + val response = controller.updateSurveyModeStatus( + id = 15L, + body = SurveyModeStatusUpdateRequest(status = SurveyModeStatus.PROCESSED) + ) + + assertEquals(org.springframework.http.HttpStatus.NO_CONTENT, response.statusCode) + verify(missionPlaningService).updateSurveyModeStatus(15L, SurveyModeStatus.PROCESSED) + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/SurveyModeStatusUpdateRequestTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/SurveyModeStatusUpdateRequestTest.kt new file mode 100644 index 0000000..f3cb048 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/SurveyModeStatusUpdateRequestTest.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import jakarta.validation.Validation +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus + +class SurveyModeStatusUpdateRequestTest { + + private val validator = Validation.buildDefaultValidatorFactory().validator + + @Test + fun `validator accepts enum status field`() { + val violations = validator.validate( + SurveyModeStatusUpdateRequest(status = SurveyModeStatus.PROCESSED) + ) + + assertTrue(violations.isEmpty()) + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisherTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisherTest.kt new file mode 100644 index 0000000..a205508 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisherTest.kt @@ -0,0 +1,63 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.kafka.clients.producer.ProducerRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.springframework.beans.factory.ObjectProvider +import org.springframework.kafka.core.KafkaTemplate +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.util.UUID + +class BookedSlotsEventPublisherTest { + + private val objectMapper = jacksonObjectMapper().findAndRegisterModules() + private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + private val publisher = BookedSlotsEventPublisher( + kafkaTemplateProvider = SingleObjectProvider(kafkaTemplate), + objectMapperProvider = SingleObjectProvider(objectMapper), + topic = "pcp.slots.status.v1", + applicationName = "pcp-mission-planing-service" + ) + + @Test + fun `publish sends booked slots lifecycle event to kafka`() { + val missionId = UUID.randomUUID() + + publisher.publish( + bookedSlotIds = listOf(10L, 20L, 10L), + status = BookedSlotStatus.PROCESSED, + missionId = missionId, + modeId = 77L, + satelliteId = 88L + ) + + val captor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor> + verify(kafkaTemplate).send(captor.capture()) + + val record = captor.value + assertEquals("pcp.slots.status.v1", record.topic()) + assertEquals(missionId.toString(), record.key()) + val typeHeader = record.headers().lastHeader("type") + assertEquals(PcpKafkaEvent.BookedSlotsStatusChangedEvent.name, typeHeader.value().decodeToString()) + + val payload = objectMapper.readTree(record.value() as String) + assertEquals(PcpKafkaEvent.BookedSlotsStatusChangedEvent.name, payload.path("type").asText()) + assertEquals("pcp-mission-planing-service", payload.path("data").path("sourceService").asText()) + assertEquals("PROCESSED", payload.path("data").path("status").asText()) + assertEquals(2, payload.path("data").path("bookedSlotIds").size()) + assertTrue(payload.path("data").path("bookedSlotIds").map { it.asLong() }.containsAll(listOf(10L, 20L))) + } + + private class SingleObjectProvider(private val value: T) : ObjectProvider { + override fun getObject(vararg args: Any?): T = value + override fun getIfAvailable(): T = value + override fun getIfUnique(): T = value + override fun getObject(): T = value + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt new file mode 100644 index 0000000..b4e7943 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt @@ -0,0 +1,459 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyList +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID +import java.math.BigDecimal + +class MissionPlaningServiceTest { + + private val missionRepository = mock(MissionRepository::class.java) + private val modeRepository = mock(ModeRepository::class.java) + private val planRepository = mock(PlanRepository::class.java) + private val surveyModeRepository = mock(SurveyModeRepository::class.java) + private val dropModeRepository = mock(DropModeRepository::class.java) + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java) + private val modeBookingRepository = mock(ModeBookingRepository::class.java) + private val bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java) + private val ballisticsService = mock(BallisticsService::class.java) + private val complexMissionService = mock(ComplexMissionService::class.java) + private val satelliteCatalogClient = mock(SatelliteCatalogClient::class.java) + private val defaultSatelliteMissionType = DefaultSatelliteMissionType() + private val satelliteMissionTypeFactory = SatelliteMissionTypeFactory( + listOf(defaultSatelliteMissionType, EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()), + defaultSatelliteMissionType + ) + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = planRepository, + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = modeBookingRepository, + bookedSlotsEventPublisher = bookedSlotsEventPublisher, + ballisticsService = ballisticsService, + complexMissionService = complexMissionService, + satelliteCatalogClient = satelliteCatalogClient, + satelliteMissionTypeFactory = satelliteMissionTypeFactory + ) + + @Test + fun `modesBySatelliteAndInterval returns only intersecting modes`() { + val mission = MissionEntity( + missionId = UUID.randomUUID(), + satelliteId = 77 + ) + val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1) + val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0) + val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0) + + val overlappingSurvey = SurveyModeEntity( + id = 1L, + plan = plan, + timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC), + revolution = 10, + lat = 55.0, + long = 37.0, + duration = 600.0, + contourWkt = "POLYGON((0 0,1 0,1 1,0 0))", + roll = 2.0, + source = SurveyModeSource.SLOT + ) + val inWindowSurvey = SurveyModeEntity( + id = 2L, + plan = plan, + timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC), + revolution = 11, + lat = 56.0, + long = 38.0, + duration = 300.0, + contourWkt = "POLYGON((0 0,1 0,1 1,0 0))", + roll = 1.0, + source = SurveyModeSource.SLOT + ) + val inWindowDrop = DropModeEntity( + id = 3L, + plan = plan, + timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC), + revolution = 12, + station = "station-1", + duration = 120.0 + ) + + `when`( + modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( + 77, + intervalStop.atOffset(ZoneOffset.UTC) + ) + ).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop)) + `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn( + listOf( + ModeBookingEntity(mode = overlappingSurvey, slotId = 1001L), + ModeBookingEntity(mode = overlappingSurvey, slotId = 1001L), + ModeBookingEntity(mode = inWindowSurvey, slotId = 2002L) + ) + ) + `when`(dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(3L)).thenReturn( + listOf( + DropModeSurveyEntity( + dropMode = inWindowDrop, + surveyMode = inWindowSurvey + ) + ) + ) + + val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop) + + assertEquals(listOf(1L, 2L, 3L), result.map { it.id }) + assertEquals(listOf(1001L), (result[0] as SurveyModeResponseDTO).bookedSlotIds) + assertEquals(listOf(2002L), (result[1] as SurveyModeResponseDTO).bookedSlotIds) + assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys) + } + + @Test + fun `modesBySatelliteAndInterval rejects inverted interval`() { + val exception = assertThrows(ResponseStatusException::class.java) { + service.modesBySatelliteAndInterval( + satelliteId = 77, + timeStart = LocalDateTime.of(2026, 3, 16, 10, 30, 0), + timeStop = LocalDateTime.of(2026, 3, 16, 10, 0, 0) + ) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.statusCode) + } + + @Test + fun `findMode searches by satellite number revolution and time start with one second tolerance`() { + val routePassport = RoutePassportDto( + routeId = UUID.randomUUID(), + kaShort = "22", + kaFull = "SATELLITE-22", + routeNameFull = "ROUTE-001", + routeNameShort = "R-001", + orbitNumber = 321, + orbitState = "ASC", + intervalBegin = LocalDateTime.of(2026, 4, 3, 12, 0, 0), + intervalEnd = LocalDateTime.of(2026, 4, 3, 12, 5, 0), + rollAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE), + visirAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE), + resolutionRange = BigDecimal.ONE, + resolutionAzimuth = BigDecimal.ONE, + polarisation = listOf("VV"), + processingLevel = "L1", + geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))", + ) + val exactMode = SurveyModeEntity( + id = 10L, + timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC), + revolution = 321 + ) + val nearMode = SurveyModeEntity( + id = 11L, + timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC), + revolution = 321 + ) + + `when`( + surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( + "22", + 321, + OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC), + OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC) + ) + ).thenReturn(listOf(nearMode, exactMode)) + + val result = service.findMode(routePassport) + + assertEquals(10L, result?.id) + } + + @Test + fun `loadSlots reads persisted stage4 modes and preserves source bookings and traceability`() { + val missionId = UUID.randomUUID() + val missionStart = LocalDateTime.of(2026, 3, 18, 10, 0) + val missionStop = LocalDateTime.of(2026, 3, 18, 12, 0) + val mission = MissionEntity( + missionId = missionId, + satelliteId = 77, + missionStart = missionStart, + missionStop = missionStop + ) + val plan = PlanEntity(id = 101L, mission = mission, planStart = missionStart, planStop = missionStop, planNumber = 1) + val stage4Mode = SatelliteModeResponseDTO( + id = 9001L, + satelliteId = 77, + source = "MANUAL", + startTime = missionStart.plusMinutes(5), + endTime = missionStart.plusMinutes(15), + revolution = 321, + lat = 55.0, + longitude = 37.0, + duration = 600.0, + contourWkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + roll = 4.0, + bookedSlotIds = listOf(501L, 502L), + snapshotId = 7001L + ) + val snapshot = SatelliteModeSnapshotResponseDTO( + id = 7001L, + satelliteId = 77, + intervalStart = missionStart.minusHours(1), + intervalEnd = missionStop.plusHours(1), + createdAt = missionStart.minusHours(1), + isActive = true + ) + var savedSurveyModes: List = emptyList() + var savedModeBookings: List = emptyList() + + `when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)) + `when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)).thenReturn(java.util.Optional.of(plan)) + `when`(satelliteCatalogClient.getSatellite(77L)).thenReturn(SatelliteDTO(id = 77L, typeCode = "Emissio")) + `when`(complexMissionService.activeSnapshot(77L)).thenReturn(snapshot) + `when`(complexMissionService.snapshotModes(snapshot.id, missionStart, missionStop)).thenReturn(listOf(stage4Mode)) + `when`(surveyModeRepository.saveAll(anyList())).thenAnswer { + savedSurveyModes = it.arguments[0] as List + savedSurveyModes + } + `when`(modeBookingRepository.saveAll(anyList())).thenAnswer { + savedModeBookings = it.arguments[0] as List + savedModeBookings + } + + val response = service.loadSlots(missionId) + + verify(complexMissionService).activeSnapshot(77L) + verify(complexMissionService).snapshotModes(snapshot.id, missionStart, missionStop) + verifyNoInteractions(bookedSlotsEventPublisher) + + val savedSurvey = savedSurveyModes.single() + assertEquals(SurveyModeSource.MANUAL, savedSurvey.source) + assertEquals(9001L, savedSurvey.satelliteModeId) + assertEquals(SurveyModeStatus.PLANNED, savedSurvey.status) + assertEquals(snapshot.id, plan.stage4SnapshotId) + assertEquals(listOf(501L, 502L), savedModeBookings.map { it.slotId }) + assertEquals(true, response.completed) + assertEquals(snapshot.id, response.comPlanSnapshotId) + assertEquals(1, response.surveyModesCount) + assertEquals(1, response.manualModesCount) + assertEquals(0, response.complanModesCount) + assertEquals(0, response.slotsModesCount) + assertEquals(0, response.mixedModesCount) + assertEquals(2, response.bookedSlotIdsCount) + } + + @Test + fun `loadSlots uses explicit satellite and interval overrides for stage5 calculation`() { + val missionId = UUID.randomUUID() + val mission = MissionEntity( + missionId = missionId, + satelliteId = 77, + missionStart = LocalDateTime.of(2026, 3, 19, 10, 0), + missionStop = LocalDateTime.of(2026, 3, 19, 12, 0) + ) + val overrideStart = LocalDateTime.of(2026, 3, 20, 8, 0) + val overrideStop = LocalDateTime.of(2026, 3, 20, 9, 0) + val overrideSatelliteId = 88L + val plan = PlanEntity(id = 111L, mission = mission, planNumber = 1) + val snapshot = SatelliteModeSnapshotResponseDTO( + id = 7002L, + satelliteId = overrideSatelliteId, + intervalStart = overrideStart, + intervalEnd = overrideStop, + createdAt = overrideStart.minusHours(1), + isActive = true + ) + + `when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)) + `when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)) + .thenReturn(java.util.Optional.of(plan)) + `when`(satelliteCatalogClient.getSatellite(overrideSatelliteId)).thenReturn(SatelliteDTO(id = overrideSatelliteId, typeCode = "Reflixio")) + `when`(complexMissionService.activeSnapshot(overrideSatelliteId)).thenReturn(snapshot) + `when`(complexMissionService.snapshotModes(snapshot.id, overrideStart, overrideStop)).thenReturn(emptyList()) + + val response = service.loadSlots(missionId, overrideSatelliteId, overrideStart, overrideStop) + + verify(complexMissionService).activeSnapshot(overrideSatelliteId) + verify(complexMissionService).snapshotModes(snapshot.id, overrideStart, overrideStop) + verify(missionRepository).save(mission) + assertEquals(overrideSatelliteId, mission.satelliteId) + assertEquals(overrideStart, mission.missionStart) + assertEquals(overrideStop, mission.missionStop) + assertEquals(snapshot.id, plan.stage4SnapshotId) + assertEquals(snapshot.id, response.comPlanSnapshotId) + assertEquals(0, response.surveyModesCount) + verifyNoInteractions(bookedSlotsEventPublisher) + } + + @Test + fun `loadSlots uses explicit com plan snapshot as primary source`() { + val missionId = UUID.randomUUID() + val missionStart = LocalDateTime.of(2026, 3, 21, 10, 0) + val missionStop = LocalDateTime.of(2026, 3, 21, 12, 0) + val mission = MissionEntity( + missionId = missionId, + satelliteId = 77, + missionStart = missionStart, + missionStop = missionStop + ) + val plan = PlanEntity(id = 112L, mission = mission, planStart = missionStart, planStop = missionStop, planNumber = 1) + val snapshot = SatelliteModeSnapshotResponseDTO( + id = 7003L, + satelliteId = 77, + intervalStart = missionStart, + intervalEnd = missionStop, + createdAt = missionStart.minusHours(1), + isActive = false + ) + + `when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)) + `when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)).thenReturn(java.util.Optional.of(plan)) + `when`(satelliteCatalogClient.getSatellite(77L)).thenReturn(SatelliteDTO(id = 77L, typeCode = "")) + `when`(complexMissionService.snapshot(snapshot.id)).thenReturn(snapshot) + `when`(complexMissionService.snapshotModes(snapshot.id, missionStart, missionStop)).thenReturn(emptyList()) + + val response = service.loadSlots(missionId, comPlanSnapshotId = snapshot.id) + + verify(complexMissionService).snapshot(snapshot.id) + verify(complexMissionService).snapshotModes(snapshot.id, missionStart, missionStop) + verify(complexMissionService, never()).activeSnapshot(77L) + assertEquals(snapshot.id, plan.stage4SnapshotId) + assertEquals(snapshot.id, response.comPlanSnapshotId) + assertEquals(0, response.surveyModesCount) + } + + @Test + fun `loadSlots fails when active stage4 snapshot is not found`() { + val missionId = UUID.randomUUID() + val missionStart = LocalDateTime.of(2026, 3, 22, 10, 0) + val missionStop = LocalDateTime.of(2026, 3, 22, 12, 0) + val mission = MissionEntity( + missionId = missionId, + satelliteId = 77, + missionStart = missionStart, + missionStop = missionStop + ) + val plan = PlanEntity(id = 113L, mission = mission, planStart = missionStart, planStop = missionStop, planNumber = 1) + + `when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)) + `when`(satelliteCatalogClient.getSatellite(77L)).thenReturn(SatelliteDTO(id = 77L, typeCode = "UNKNOWN")) + `when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)).thenReturn(java.util.Optional.of(plan)) + `when`(complexMissionService.activeSnapshot(77L)).thenReturn(null) + + val exception = assertThrows(CustomErrorException::class.java) { + service.loadSlots(missionId) + } + + assertEquals( + "Active stage4 snapshot not found for satelliteId=77 and interval=[$missionStart - $missionStop]", + exception.message + ) + verify(complexMissionService).activeSnapshot(77L) + } + + @Test + fun `confirmMission publishes processed booked slots event`() { + val missionId = UUID.randomUUID() + val mission = MissionEntity(missionId = missionId, satelliteId = 77) + val surveyMode = SurveyModeEntity(id = 11L) + + `when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission)) + `when`(missionRepository.save(mission)).thenReturn(mission) + `when`(surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)) + .thenReturn(listOf(surveyMode)) + `when`(modeBookingRepository.findAllByMode_IdIn(listOf(11L))) + .thenReturn(listOf(ModeBookingEntity(mode = surveyMode, slotId = 1001L))) + + val result = service.confirmMission(missionId) + + assertEquals(listOf(1001L), result) + verify(bookedSlotsEventPublisher).publish( + listOf(1001L), + BookedSlotStatus.PROCESSED, + missionId, + null, + 77L + ) + } + + @Test + fun `updateSurveyModeStatus publishes terminal booked slot event`() { + val missionId = UUID.randomUUID() + val mission = MissionEntity(missionId = missionId, satelliteId = 77) + val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1) + val surveyMode = SurveyModeEntity(id = 99L, plan = plan, status = SurveyModeStatus.PLANNED) + + `when`(surveyModeRepository.findById(99L)).thenReturn(java.util.Optional.of(surveyMode)) + `when`(surveyModeRepository.save(surveyMode)).thenReturn(surveyMode) + `when`(modeBookingRepository.findAllByMode_IdIn(listOf(99L))) + .thenReturn(listOf(ModeBookingEntity(mode = surveyMode, slotId = 501L))) + + service.updateSurveyModeStatus(99L, SurveyModeStatus.PROCESSED) + + assertEquals(SurveyModeStatus.PROCESSED, surveyMode.status) + verify(bookedSlotsEventPublisher).publish( + listOf(501L), + BookedSlotStatus.FINISHED, + missionId, + 99L, + 77L + ) + } + + @Test + fun `updateSurveyModeStatus without bookings does not publish event`() { + val surveyMode = SurveyModeEntity(id = 77L, status = SurveyModeStatus.PLANNED) + + `when`(surveyModeRepository.findById(77L)).thenReturn(java.util.Optional.of(surveyMode)) + `when`(surveyModeRepository.save(surveyMode)).thenReturn(surveyMode) + `when`(modeBookingRepository.findAllByMode_IdIn(listOf(77L))).thenReturn(emptyList()) + + service.updateSurveyModeStatus(77L, SurveyModeStatus.FAILED) + + assertEquals(SurveyModeStatus.FAILED, surveyMode.status) + verifyNoInteractions(bookedSlotsEventPublisher) + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ModeStatusChangedKafkaListenerTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ModeStatusChangedKafkaListenerTest.kt new file mode 100644 index 0000000..b7eac36 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/ModeStatusChangedKafkaListenerTest.kt @@ -0,0 +1,138 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +class ModeStatusChangedKafkaListenerTest { + + private val objectMapper = jacksonObjectMapper().findAndRegisterModules() + private val missionPlaningService = mock(MissionPlaningService::class.java) + private val listener = ModeStatusChangedKafkaListener( + objectMapperProvider = SingleObjectProvider(objectMapper), + missionPlaningService = missionPlaningService, + ) + + @Test + fun `consume finds mode and updates its status`() { + val routePassport = routePassportDto(routeStatus = SurveyModeStatus.PROCESSED) + val mode = SurveyModeEntity(id = 15L) + val appender = attachAppender() + val payload = objectMapper.writeValueAsString( + KafkaMessage( + type = PcpKafkaEvent.ModeStatusChangedEvent, + data = routePassport, + ).apply { + source = "route-processing" + } + ) + `when`(missionPlaningService.findMode(routePassport)).thenReturn(mode) + + listener.consume(payload) + + verify(missionPlaningService).findMode(routePassport) + verify(missionPlaningService).updateSurveyModeStatus(15L, SurveyModeStatus.PROCESSED) + val infoLog = appender.list.last { it.level == Level.INFO } + assertTrue(infoLog.formattedMessage.contains("Mode status changed event processed")) + assertTrue(infoLog.formattedMessage.contains("surveyModeId=15")) + } + + @Test + fun `consume logs error when mode is not found`() { + val routePassport = routePassportDto(routeStatus = SurveyModeStatus.FAILED) + val appender = attachAppender() + val payload = objectMapper.writeValueAsString( + KafkaMessage( + type = PcpKafkaEvent.ModeStatusChangedEvent, + data = routePassport, + ).apply { + source = "route-processing" + } + ) + `when`(missionPlaningService.findMode(routePassport)).thenReturn(null) + + listener.consume(payload) + + verify(missionPlaningService).findMode(routePassport) + verify(missionPlaningService, never()).updateSurveyModeStatus(15L, SurveyModeStatus.FAILED) + val errorLog = appender.list.last { it.level == Level.ERROR } + assertTrue(errorLog.formattedMessage.contains("Survey mode not found for ModeStatusChangedEvent")) + assertTrue(errorLog.formattedMessage.contains("routeStatus=FAILED")) + } + + @Test + fun `consume ignores unsupported kafka event type`() { + val appender = attachAppender() + val payload = objectMapper.writeValueAsString( + KafkaMessage( + type = PcpKafkaEvent.RouteGeoRefEvent, + data = routePassportDto(), + ) + ) + + listener.consume(payload) + + val debugLog = appender.list.last { it.level == Level.DEBUG } + assertEquals("Ignoring Kafka message with unsupported type RouteGeoRefEvent", debugLog.formattedMessage) + verifyNoInteractions(missionPlaningService) + } + + private fun attachAppender(): ListAppender { + val logger = LoggerFactory.getLogger(ModeStatusChangedKafkaListener::class.java) as Logger + logger.detachAndStopAllAppenders() + val appender = ListAppender() + appender.start() + logger.addAppender(appender) + logger.level = Level.DEBUG + return appender + } + + private fun routePassportDto(routeStatus: SurveyModeStatus = SurveyModeStatus.PROCESSED): RoutePassportDto = + RoutePassportDto( + routeId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + kaShort = "22", + kaFull = "SATELLITE-22", + routeNameFull = "ROUTE-001", + routeNameShort = "R-001", + orbitNumber = 42, + orbitState = "ASC", + intervalBegin = LocalDateTime.of(2026, 4, 3, 12, 0), + intervalEnd = LocalDateTime.of(2026, 4, 3, 12, 5), + rollAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE), + visirAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE), + resolutionRange = BigDecimal.ONE, + resolutionAzimuth = BigDecimal.ONE, + polarisation = listOf("VV"), + processingLevel = "L1", + geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))", + routeStatus = routeStatus, + ) + + private class SingleObjectProvider(private val value: T) : ObjectProvider { + override fun getObject(vararg args: Any?): T = value + override fun getIfAvailable(): T = value + override fun getIfUnique(): T = value + override fun getObject(): T = value + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedServiceTest.kt new file mode 100644 index 0000000..1b0b839 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedServiceTest.kt @@ -0,0 +1,34 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository + +class SatelliteDeletedServiceTest { + + @Test + fun `deleteSatelliteData removes missions and cascaded plans and modes`() { + val missionRepository = mock(MissionRepository::class.java) + val planRepository = mock(PlanRepository::class.java) + val modeRepository = mock(ModeRepository::class.java) + val service = SatelliteDeletedService(missionRepository, planRepository, modeRepository) + + `when`(missionRepository.countBySatelliteId(56756L)).thenReturn(2L) + `when`(planRepository.countByMissionSatelliteId(56756L)).thenReturn(3L) + `when`(modeRepository.countByPlan_Mission_SatelliteId(56756L)).thenReturn(5L) + `when`(missionRepository.deleteAllBySatelliteId(56756L)).thenReturn(2L) + + val summary = service.deleteSatelliteData(56756L) + + assertEquals(56756L, summary.satelliteId) + assertEquals(2L, summary.missionsDeleted) + assertEquals(3L, summary.plansDeletedByCascade) + assertEquals(5L, summary.modesDeletedByCascade) + verify(missionRepository).deleteAllBySatelliteId(56756L) + } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionTypeFactoryTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionTypeFactoryTest.kt new file mode 100644 index 0000000..466831e --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/SatelliteMissionTypeFactoryTest.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type + +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO + +class SatelliteMissionTypeFactoryTest { + private val defaultType = DefaultSatelliteMissionType() + private val emissioType = EmissioSatelliteMissionType() + private val reflixioType = ReflixioSatelliteMissionType() + private val factory = SatelliteMissionTypeFactory( + missionTypes = listOf(defaultType, emissioType, reflixioType), + defaultMissionType = defaultType + ) + + @Test + fun `returns Emissio type by satellite dto typeCode ignoring case`() { + val type = factory.getBySatellite(SatelliteDTO(id = 1, typeCode = "Emissio")) + + assertSame(emissioType, type) + } + + @Test + fun `returns Reflixio type by satellite dto typeCode ignoring case`() { + val type = factory.getBySatellite(SatelliteDTO(id = 2, typeCode = "reflixio")) + + assertSame(reflixioType, type) + } + + @Test + fun `returns default type for blank or unknown typeCode`() { + assertSame(defaultType, factory.getBySatellite(SatelliteDTO(id = 3, typeCode = ""))) + assertSame(defaultType, factory.getBySatellite(SatelliteDTO(id = 4, typeCode = "unknown"))) + } +} diff --git a/services/pcp-mission-planing-service/src/test/resources/application.yaml b/services/pcp-mission-planing-service/src/test/resources/application.yaml new file mode 100644 index 0000000..b2ba0b7 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/resources/application.yaml @@ -0,0 +1,29 @@ +spring: + config: + import: "optional:configserver:" + cloud: + config: + enabled: false + datasource: + url: jdbc:h2:mem:pcp_mission_planing;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + flyway: + enabled: false + +camunda: + client: + enabled: false + +settings: + ballistics-service: http://localhost:7003 + complex-mission-service: http://localhost:7002 + satellite-catalog-service: http://localhost:7013 + stations-service: http://localhost:7009 diff --git a/services/pcp-request-service/Dockerfile b/services/pcp-request-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-request-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-request-service/build.gradle.kts b/services/pcp-request-service/build.gradle.kts new file mode 100644 index 0000000..1bfa2f8 --- /dev/null +++ b/services/pcp-request-service/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +group = "space.nstart.pcp" +version = "1.0.0" + +dependencies { + implementation(project(":libs:pcp-types-lib")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("org.locationtech.jts:jts-core:1.19.0") + implementation("org.geotools:gt-main:31.0") + implementation("org.geotools:gt-referencing:31.0") + implementation("org.geotools:gt-epsg-hsql:31.0") + implementation("org.geotools:gt-epsg-extension:31.0") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation(kotlin("test")) + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("com.h2database:h2") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/services/pcp-request-service/docs/AsyncAPIRequestPCP.yaml b/services/pcp-request-service/docs/AsyncAPIRequestPCP.yaml new file mode 100644 index 0000000..336b99c --- /dev/null +++ b/services/pcp-request-service/docs/AsyncAPIRequestPCP.yaml @@ -0,0 +1,1269 @@ +asyncapi: 2.6.0 +info: + title: PCP RequestTask API + version: 0.0.1 + description: "Сервис для приема и подтверждения заявок на съемку, а так же для формирования\ + \ и \nбронирования схем покрытия заявок.\n" +servers: + production: + url: kafka://broker.example.com:9092 + protocol: kafka +defaultContentType: application/json +channels: + pcp.slot.cover.task: + description: 'СПО ПЦП подписывается на заказ схемы покрытия. + + Содержимое сообщения содержит заявку на съемку. + + ' + subscribe: + summary: Запрос на схему покрытия. + message: + $ref: '#/components/messages/SlotCoverRequest' + pcp.slot.cover.ready: + description: 'СПО ПЦП публикует сформированную схему покрытия. + + Содержимое сообщения содержит схему покрытия. + + ' + publish: + summary: Публикация схемы покрытия слотами. + message: + $ref: '#/components/messages/SlotCoverReady' + pcp.slot.cover.book: + description: 'СПО ПЦП подписывается на топик с запросом на бронирование схемы + покрытия. + + Содержимое сообщения содержит идентификаторы слотов схемы покрытия, утвержденную + потребителем. + + ' + subscribe: + summary: Запрос на бронирование слотов. + message: + $ref: '#/components/messages/SlotCoverBooking' + pcp.slot.cover.ack: + description: 'СПО ПЦП публикует подтверждение бронирования схемы покрытия. + + Содержимое сообщения содержит идентификаторы слотов подтвержденной схемы покрытия. + + ' + publish: + summary: Публикация подтверждения бронирования слотов. + message: + $ref: '#/components/messages/SlotCoverCommited' + pcp.spacecraft.plan.ready: + description: СПО ПЦП публикует сформированный план работы КА. + publish: + summary: Публикация плана работы КА. + message: + $ref: '#/components/messages/SpacecraftPlanReady' + pcp.groundstation.change: + description: СПО ПЦП подписывается на уведомления об изменении параметров земных + станций (без передачи данных). + subscribe: + summary: Факт изменения параметров ЗС. + message: + $ref: '#/components/messages/GroundStationChanged' + pcp.spacecraft.nu.change: + description: СПО ПЦП подписывается на уведомления об изменении начальных условий + КА (без передачи данных). + subscribe: + summary: Факт изменения начальных условий КА. + message: + $ref: '#/components/messages/SpacecraftNuChanged' + pcp.spacecraft.bls.change: + description: СПО ПЦП подписывается на уведомления об изменении блока состояния + КА (без передачи данных). + subscribe: + summary: Факт изменения блока состояния КА. + message: + $ref: '#/components/messages/SpacecraftBlsChanged' + pcp.spacecraft.control.time: + description: СПО ПЦП подписывается на события о приходе/обновлении графика управления + КА. + subscribe: + summary: Приход графика управления КА. + message: + $ref: '#/components/messages/SpacecraftControlTime' +components: + messages: + SlotCoverRequest: + name: slotCoverRequest + title: Схема покрытия. + summary: Запрос схемы покрытия от СПО ПЦП. + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/Request' + bindings: + kafka: + valueFormat: json + SlotCoverReady: + name: slotCoverReady + title: Схема покрытия. + summary: Сформированная схема покрытия. + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + type: array + items: + $ref: '#/components/schemas/SurveySlot' + description: Список слотов (схема покрытия) + bindings: + kafka: + valueFormat: json + SlotCoverBooking: + name: slotCoverBooking + title: Схема покрытия, бронирование. + summary: Запрос на бронирование схемы покрытия. + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + properties: + slots: + type: array + items: + $ref: '#/components/schemas/SurveySlotInfo' + description: Список слотов для бронирования. + request: + $ref: '#/components/schemas/Request' + type: object + required: + - slots + - request + bindings: + kafka: + valueFormat: json + SlotCoverCommited: + name: slotCoverCommitted + title: Схема покрытия, бронирование. + summary: Подтверждение от СПО ПЦП бронирования схемы покрытия. + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + type: array + items: + $ref: '#/components/schemas/SurveySlotInfo' + description: Список забронированных слотов. + bindings: + kafka: + valueFormat: json + SpacecraftPlanReady: + name: SpacecraftPlanReady + title: План работы КА готов + summary: Публикация сформированного плана работы КА (маршруты съемки + сбросы) + для потребителей. + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/SpacecraftWorkPlan' + bindings: + kafka: + valueFormat: json + GroundStationChanged: + name: GroundStationChanged + title: Изменение параметров земной станции + summary: Уведомление о факте изменения параметров земной станции (без передачи + данных) (payload содержит только идентификатор). + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/GroundStationChangedPayload' + bindings: + kafka: + valueFormat: json + examples: + - payload: + stationId: 101 + SpacecraftNuChanged: + name: SpacecraftNuChanged + title: Изменение начальных условий КА + summary: Уведомление о факте изменения начальных условий КА (без передачи данных) + (payload содержит только идентификатор). + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/SpacecraftChangedPayload' + bindings: + kafka: + valueFormat: json + examples: + - payload: + spacecraftId: 205 + SpacecraftBlsChanged: + name: SpacecraftBlsChanged + title: Изменение параметров блока состояния КА + summary: Уведомление о факте изменения блока состояния КА (без передачи данных) + (payload содержит только идентификатор). + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/SpacecraftChangedPayload' + bindings: + kafka: + valueFormat: json + examples: + - payload: + spacecraftId: 205 + SpacecraftControlTime: + name: SpacecraftControlTime + title: График управления КА + summary: Событие о приходе/обновлении графика управления КА (основная/корректирующая + закладки и интервал планирования). + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/SpacecraftControlTimeEvent' + bindings: + kafka: + valueFormat: json + examples: + - payload: + spacecraftId: 205 + mainUploadAt: '2026-02-20T10:00:00' + correctionUploadAt: '2026-02-20T14:00:00' + planningBegin: '2026-02-20T00:00:00' + planningEnd: '2026-02-27T00:00:00' + schemas: + Request: + type: object + description: Заявка на съемку. Необходимо указать хотя бы одно требование по + аппаратуре. + properties: + id: + type: string + format: uuid + description: Идентификатор заявки UUID. + name: + type: string + maxLength: 255 + description: Наименование заявки. Не более 255 символов. + kpp: + type: array + items: + type: integer + description: Список номеров КПП для сброса. Если не указано, сброс производится + на все доступные КПП. + highPriorityTransmit: + type: boolean + default: false + description: Признак высокоприоритетной передачи. + intervalBegin: + type: string + format: date-time + description: Дата‑время начала выполнения заявки. Не может быть менее чем + сегодня. + intervalEnd: + type: string + format: date-time + description: Дата‑время конца выполнения заявки. Минимальный интервал съемки + - 7 дней. + optics: + $ref: '#/components/schemas/OpticsConstraints' + nullable: true + description: Ограничения по оптике (null – не используется). + rsa: + $ref: '#/components/schemas/RSAConstraints' + nullable: true + description: Ограничения по РСА (null – не используется). + geometry: + type: string + format: wkt + description: Геометрия заявки в формате WKT. Геометрия должна быть Polygon. + additionalProperties: false + required: + - id + - name + - intervalBegin + - intervalEnd + - geometry + anyOf: + - required: + - optics + - required: + - rsa + RSAConstraints: + type: object + description: Ограничения на съемку, РСА + properties: + resultType: + $ref: '#/components/schemas/RsaResultType' + description: Режим работы РСА (детальный(Spotlight), маршрутный(StripMap), + обзорный(ScanSar)) + interferometry: + type: boolean + description: Признак интерферометрии + polarisation: + $ref: '#/components/schemas/PolarisationType' + description: Поляризация + resolution: + type: number + format: double + default: 20 + description: Требуемое разрешение, м. + additionalProperties: false + required: + - resultType + - resolution + - polarisation + OpticsConstraints: + type: object + description: Ограничения на съемку, оптика. + properties: + resultType: + $ref: '#/components/schemas/OpticsResultType' + description: Тип требуемого продукта (панхром, мультиспектр, паншарпенинг). + resolution: + type: number + format: double + description: Требуемое разрешение, м. + sunAngleMin: + type: number + format: double + default: 0 + minimum: 0 + maximum: 90 + description: Минимальный угол солнца. + sunAngleMax: + type: number + format: double + default: 90 + minimum: 0 + maximum: 90 + description: Максимальный угол солнца. + clouds: + type: number + format: double + default: 100 + minimum: 0 + maximum: 100 + description: Облачность. Максимально допустимое значение. + additionalProperties: false + required: + - resultType + - resolution + PolarisationType: + type: string + enum: + - HH + - VV + description: Тип поляризации. + OpticsResultType: + type: string + enum: + - PANCHROMATIC + - MULTISPECTRAL + - PANSHARPENING + description: Результат оптической съемки. + RsaResultType: + type: string + enum: + - SPOTLIGHT + - STRIPMAP + - SCANSAR + description: Режим работы РСА. + SurveySlot: + type: object + description: Слот наблюдения (часть схемы покрытия). + properties: + slotNumber: + type: integer + format: int64 + default: 0 + description: Номер слота. + cycle: + type: integer + format: int64 + default: 0 + description: Цикл замыкания. + ka: + type: integer + format: int64 + default: 0 + description: Идентификатор КА. + tn: + type: string + format: date-time + description: Время начала наблюдения слота. + tk: + type: string + format: date-time + description: Время конца наблюдения слота. + roll: + type: number + format: double + description: Угол визирования. + contour: + type: string + description: Геометрия слота, WKT, WGS 84. + revolution: + type: integer + format: int64 + default: 0 + description: Виток наблюдения. + revolutionSign: + $ref: '#/components/schemas/RevolutionSign' + description: Признак ветви (восходящая/нисходящая). + state: + type: integer + format: int16 + default: 0 + description: Статус бронирования (0 - нет, 1 - да). + additionalProperties: false + required: + - cycle + - ka + - tn + - tk + - roll + - contour + - revolution + - revolutionSign + - slotNumber + - state + SurveySlotInfo: + type: object + description: Краткая информация слота, необходимая для бронирования. + properties: + slotNumber: + type: integer + format: int64 + default: 0 + description: Номер слота. + cycle: + type: integer + format: int64 + default: 0 + description: Цикл замыкания. + noradId: + type: integer + format: int64 + default: 0 + description: Идентификатор НОРАД. + additionalProperties: false + required: + - slotNumber + - cycle + - noradId + RevolutionSign: + type: string + enum: + - ASC + - DESC + description: Признак ветви – восходящая/нисходящая. + RequestType: + type: string + enum: + - RESERVATION + - COMMON + description: Тип заявки. Бронь или обычная. + EventType: + type: string + description: Тип события. Значение обязательно и используется для маршрутизации + и диагностики. + enum: + - NEW_REQUEST + - NEW_REQUEST_COMMITED + - REQUEST_CANCEL + - REQUEST_CANCEL_COMMITED + - ORDER_SLOT_COVER + - SLOT_COVER_READY + - SLOT_COVER_BOOKING + - SLOT_COVER_BOOKED + - GROUNDSTATION_CHANGED + - SPACECRAFT_NU_CHANGED + - SPACECRAFT_BLS_CHANGED + EventHeaders: + type: object + required: + - event_id + - event_type + - produced_at + properties: + event_id: + type: string + description: Уникальный идентификатор события. + event_type: + $ref: '#/components/schemas/EventType' + produced_at: + type: string + format: date-time + description: Время формирования события. + trace_id: + type: string + nullable: true + description: Идентификатор трассировки. + schema_version: + type: string + nullable: true + description: Версия схемы сообщения. + example: v1 + SpacecraftWorkPlan: + type: object + description: План работы КА + properties: + planningBegin: + type: string + format: date-time + planningEnd: + type: string + format: date-time + imagingRoutes: + type: array + items: + $ref: '#/components/schemas/RoutePlanItem' + default: [] + downlinks: + type: array + items: + $ref: '#/components/schemas/DownlinkPlanItem' + default: [] + required: + - planningBegin + - planningEnd + additionalProperties: false + RoutePlanItem: + type: object + description: Маршрут съемки в плане работы КА + properties: + activationNumber: + type: integer + format: int32 + routeNumber: + type: integer + format: int32 + revolution: + type: integer + format: int64 + routeOnRevolutionNumber: + type: integer + format: int32 + startTime: + type: string + format: date-time + durationSec: + type: integer + format: int64 + payloadType: + $ref: '#/components/schemas/PayloadType' + targetWkt: + type: string + description: Координаты точки прицеливания (например WKT) + rollDeg: + type: number + format: double + pitchDeg: + type: number + format: double + yawDeg: + type: number + format: double + sunAngleBeginDeg: + type: number + format: double + sunAngleEndDeg: + type: number + format: double + slantRangeBeginMeters: + type: number + format: double + slantRangeEndMeters: + type: number + format: double + required: + - activationNumber + - routeNumber + - revolution + - routeOnRevolutionNumber + - startTime + - durationSec + - payloadType + - targetWkt + - rollDeg + - pitchDeg + - yawDeg + - sunAngleBeginDeg + - sunAngleEndDeg + - slantRangeBeginMeters + - slantRangeEndMeters + additionalProperties: false + DownlinkPlanItem: + type: object + description: Сброс в плане работы КА + properties: + activationNumber: + type: integer + format: int32 + downlinkNumber: + type: integer + format: int32 + revolution: + type: integer + format: int64 + stationId: + type: integer + format: int64 + begin: + type: string + format: date-time + end: + type: string + format: date-time + routeKeys: + type: array + items: + $ref: '#/components/schemas/RouteKey' + default: [] + required: + - activationNumber + - downlinkNumber + - revolution + - stationId + - begin + - end + additionalProperties: false + RouteKey: + type: object + description: Составной ключ маршрута (порядковый номер включения + номер витка + + номер на витке) + properties: + activationNumber: + type: integer + format: int32 + revolution: + type: integer + format: int64 + routeOnRevolutionNumber: + type: integer + format: int32 + required: + - activationNumber + - revolution + - routeOnRevolutionNumber + additionalProperties: false + PayloadType: + type: string + description: Данные по аппаратуре + enum: + - RADAR + - OPTICS + GroundStationCoordinates: + type: object + description: 'Координаты ЗС в WGS-84. + + Можно передавать либо b/l/h, либо WKT Point(x,y,z). + + ' + properties: + b: + type: number + format: double + description: Широта (градусы) + l: + type: number + format: double + description: Долгота (градусы) + h: + type: number + format: double + description: Превышение/высота (метры) + wktPoint: + type: string + description: WKT Point (x + y: null + z): null + additionalProperties: false + GroundStationParams: + type: object + description: Параметры земной станции (ЗС) + properties: + stationId: + type: integer + format: int64 + description: Идентификатор ЗС + coordinates: + $ref: '#/components/schemas/GroundStationCoordinates' + minAngleDeg: + type: number + format: double + description: Минимальный угол (градусы) + maxAngleDeg: + type: number + format: double + description: Максимальный угол (градусы) + retargetingTimeSec: + type: integer + format: int64 + description: Время перенацеливания (секунды) + receiveIntervalSec: + type: integer + format: int64 + description: Интервал между последовательными приемами (секунды) + required: + - stationId + - coordinates + - minAngleDeg + - maxAngleDeg + - retargetingTimeSec + - receiveIntervalSec + additionalProperties: false + GroundStationBanInterval: + type: object + description: Интервал запрета на работу с ЗС + properties: + stationId: + type: integer + format: int64 + description: Идентификатор ЗС + begin: + type: string + format: date-time + description: Время начала интервала запрета + end: + type: string + format: date-time + description: Время конца интервала запрета + required: + - stationId + - begin + - end + additionalProperties: false + RadarStateConstraints: + type: object + description: Ограничения для локации (РСА) на уровне блока состояния + properties: + scanSarRouteMinDurationSec: + type: integer + format: int64 + scanSarRouteMaxDurationSec: + type: integer + format: int64 + stripMapRouteMinDurationSec: + type: integer + format: int64 + stripMapRouteMaxDurationSec: + type: integer + format: int64 + scanSarMaxModesPerOrbit: + type: integer + format: int32 + scanSarMaxModesPerDay: + type: integer + format: int32 + stripMapMaxModesPerOrbit: + type: integer + format: int32 + stripMapMaxModesPerDay: + type: integer + format: int32 + spotLightMaxModesPerOrbit: + type: integer + format: int32 + spotLightMaxModesPerDay: + type: integer + format: int32 + required: + - scanSarRouteMinDurationSec + - scanSarRouteMaxDurationSec + - stripMapRouteMinDurationSec + - stripMapRouteMaxDurationSec + - scanSarMaxModesPerOrbit + - scanSarMaxModesPerDay + - stripMapMaxModesPerOrbit + - stripMapMaxModesPerDay + - spotLightMaxModesPerOrbit + - spotLightMaxModesPerDay + additionalProperties: false + OpticsStateConstraints: + type: object + description: Ограничения для оптики на уровне блока состояния + properties: + pssCaptureAngleDeg: + type: number + format: double + mssCaptureAngleDeg: + type: number + format: double + mssMaxDurationPerOrbitSec: + type: integer + format: int64 + mssMaxDurationPerDaySec: + type: integer + format: int64 + pssMaxDurationPerOrbitSec: + type: integer + format: int64 + pssMaxDurationPerDaySec: + type: integer + format: int64 + jointMaxDurationPerOrbitSec: + type: integer + format: int64 + jointMaxDurationPerDaySec: + type: integer + format: int64 + required: + - pssCaptureAngleDeg + - mssCaptureAngleDeg + - mssMaxDurationPerOrbitSec + - mssMaxDurationPerDaySec + - pssMaxDurationPerOrbitSec + - pssMaxDurationPerDaySec + - jointMaxDurationPerOrbitSec + - jointMaxDurationPerDaySec + additionalProperties: false + SpacecraftStateBlock: + type: object + description: Блок состояния КА + properties: + spacecraftId: + type: integer + format: int64 + description: Идентификатор КА + stationIds: + type: array + description: Перечень станций (идентификаторы ЗС), с которыми работает аппарат + items: + type: integer + format: int64 + default: [] + rollMinDeg: + type: number + format: double + rollMaxDeg: + type: number + format: double + pitchMinDeg: + type: number + format: double + pitchMaxDeg: + type: number + format: double + yawMinDeg: + type: number + format: double + yawMaxDeg: + type: number + format: double + imagingMaxDurationPerDaySec: + type: integer + format: int64 + imagingMaxDurationPerOrbitSec: + type: integer + format: int64 + downlinkMaxDurationPerDaySec: + type: integer + format: int64 + downlinkMaxDurationPerOrbitSec: + type: integer + format: int64 + imagingModesPerOrbit: + type: integer + format: int32 + imagingModesPerDay: + type: integer + format: int32 + downlinkModesPerOrbit: + type: integer + format: int32 + downlinkModesPerDay: + type: integer + format: int32 + imagingToImagingIntervalSec: + type: integer + format: int64 + downlinkToDownlinkIntervalSec: + type: integer + format: int64 + imagingToDownlinkIntervalSec: + type: integer + format: int64 + downlinkToImagingIntervalSec: + type: integer + format: int64 + minInterRouteIntervalSec: + type: integer + format: int64 + radarConstraints: + $ref: '#/components/schemas/RadarStateConstraints' + opticsConstraints: + $ref: '#/components/schemas/OpticsStateConstraints' + required: + - spacecraftId + - rollMinDeg + - rollMaxDeg + - pitchMinDeg + - pitchMaxDeg + - yawMinDeg + - yawMaxDeg + - imagingMaxDurationPerDaySec + - imagingMaxDurationPerOrbitSec + - downlinkMaxDurationPerDaySec + - downlinkMaxDurationPerOrbitSec + - imagingModesPerOrbit + - imagingModesPerDay + - downlinkModesPerOrbit + - downlinkModesPerDay + - imagingToImagingIntervalSec + - downlinkToDownlinkIntervalSec + - imagingToDownlinkIntervalSec + - downlinkToImagingIntervalSec + - minInterRouteIntervalSec + - radarConstraints + - opticsConstraints + additionalProperties: false + PdcmEntry: + type: object + description: Запись ПДЦМ (позиция/скорость) на момент времени + properties: + time: + type: string + format: date-time + revolution: + type: integer + format: int64 + vx: + type: number + format: double + vy: + type: number + format: double + vz: + type: number + format: double + x: + type: number + format: double + y: + type: number + format: double + z: + type: number + format: double + required: + - time + - revolution + - vx + - vy + - vz + - x + - y + - z + additionalProperties: false + PdcmInterval: + type: object + description: ПДЦМ на интервал (оперативный или программной орбиты) + properties: + spacecraftId: + type: integer + format: int64 + begin: + type: string + format: date-time + end: + type: string + format: date-time + entries: + type: array + items: + $ref: '#/components/schemas/PdcmEntry' + required: + - spacecraftId + - begin + - end + - entries + additionalProperties: false + UploadSchedule: + type: object + description: График закладки (основная/корректирующая + интервал планирования) + properties: + spacecraftId: + type: integer + format: int64 + mainUploadAt: + type: string + format: date-time + correctiveUploadAt: + type: string + format: date-time + planningBegin: + type: string + format: date-time + planningEnd: + type: string + format: date-time + required: + - spacecraftId + - mainUploadAt + - correctiveUploadAt + - planningBegin + - planningEnd + additionalProperties: false + SpacecraftInitialConditions: + type: object + description: Начальные условия КА для расчётов (состояние в момент epoch). + properties: + spacecraftId: + type: integer + format: int64 + description: Идентификатор КА. + epoch: + type: string + format: date-time + description: Момент времени, для которого заданы начальные условия (UTC). + frame: + type: string + description: Система координат/фрейм (например, ECI). + default: ECI + positionMeters: + type: array + description: Вектор положения [x,y,z] в метрах в указанной системе координат. + items: + type: number + format: double + minItems: 3 + maxItems: 3 + velocityMetersPerSec: + type: array + description: Вектор скорости [vx,vy,vz] в м/с в указанной системе координат. + items: + type: number + format: double + minItems: 3 + maxItems: 3 + source: + type: string + nullable: true + description: Источник/поставщик расчёта начальных условий (опционально). + version: + type: string + nullable: true + description: Версия/ревизия набора начальных условий (опционально). + required: + - spacecraftId + - epoch + - positionMeters + - velocityMetersPerSec + additionalProperties: false + ChangeType: + type: string + enum: + - CREATED + - UPDATED + - DELETED + description: Тип изменения + EntityType: + type: string + description: Тип сущности, которая изменилась + enum: + - GROUND_STATION + - GROUND_STATION_BAN_INTERVALS + - SPACECRAFT_STATE_BLOCK + - SPACECRAFT_NU + - UPLOAD_SCHEDULE + - SPACECRAFT_WORKPLAN + - CONTROL_TIME + ChangeEvent: + type: object + description: Уведомление о факте изменения данных у поставщика. Сами данные + передаются/запрашиваются отдельно. + required: + - eventId + - occurredAt + - entityType + - entityIds + - changeType + properties: + eventId: + type: string + format: uuid + description: Уникальный идентификатор события + occurredAt: + type: string + format: date-time + description: Время изменения у поставщика (UTC/ISO 8601). + entityType: + $ref: '#/components/schemas/EntityType' + entityIds: + type: array + minItems: 1 + items: + type: integer + format: int64 + description: Идентификаторы сущностей, затронутых изменением + changeType: + $ref: '#/components/schemas/ChangeType' + effectiveFrom: + type: string + format: date-time + nullable: true + description: (Опционально) начало интервала влияния + effectiveTo: + type: string + format: date-time + nullable: true + description: (Опционально) конец интервала влияния + version: + type: string + nullable: true + description: (Опционально) версия/ревизия данных у поставщика + reason: + type: string + nullable: true + description: (Опционально) пояснение причины изменения + correlationId: + type: string + nullable: true + description: (Опционально) идентификатор корреляции + source: + type: string + nullable: true + description: (Опционально) идентификатор поставщика/источника + additionalProperties: false + SpacecraftControlTimeEvent: + type: object + description: Сообщение о приходе (или обновлении) графика управления КА. Все + времена — LocalDateTime (без часового пояса). + required: + - spacecraftId + - mainUploadAt + - correctionUploadAt + - planningBegin + - planningEnd + properties: + spacecraftId: + type: integer + format: int64 + description: Идентификатор КА. + mainUploadAt: + type: string + format: date-time + description: Дата-время основной закладки (LocalDateTime, без offset). + correctionUploadAt: + type: string + format: date-time + description: Дата-время корректирующей закладки (LocalDateTime, без offset). + planningBegin: + type: string + format: date-time + description: Начало интервала планирования (LocalDateTime, без offset). + planningEnd: + type: string + format: date-time + description: Конец интервала планирования (LocalDateTime, без offset). + additionalProperties: false + GroundStationChangeEvent: + type: object + description: Уведомление о факте изменения данных земной станции (без передачи + данных). + required: + - eventId + - occurredAt + - changeType + - stationId + properties: + eventId: + type: string + format: uuid + description: Уникальный идентификатор события + occurredAt: + type: string + format: date-time + description: Время изменения у поставщика (UTC/ISO 8601). + changeType: + $ref: '#/components/schemas/ChangeType' + stationId: + type: integer + format: int64 + description: Идентификатор земной станции. + version: + type: string + nullable: true + description: (Опционально) версия/ревизия данных у поставщика + reason: + type: string + nullable: true + description: (Опционально) пояснение причины изменения + correlationId: + type: string + nullable: true + description: (Опционально) идентификатор корреляции + source: + type: string + nullable: true + description: (Опционально) идентификатор поставщика/источника + additionalProperties: false + SpacecraftChangeEvent: + type: object + description: Уведомление о факте изменения данных КА (без передачи данных). + required: + - eventId + - occurredAt + - changeType + - spacecraftId + properties: + eventId: + type: string + format: uuid + description: Уникальный идентификатор события + occurredAt: + type: string + format: date-time + description: Время изменения у поставщика (UTC/ISO 8601). + changeType: + $ref: '#/components/schemas/ChangeType' + spacecraftId: + type: integer + format: int64 + description: Идентификатор космического аппарата. + version: + type: string + nullable: true + description: (Опционально) версия/ревизия данных у поставщика + reason: + type: string + nullable: true + description: (Опционально) пояснение причины изменения + correlationId: + type: string + nullable: true + description: (Опционально) идентификатор корреляции + source: + type: string + nullable: true + description: (Опционально) идентификатор поставщика/источника + additionalProperties: false + GroundStationChangedPayload: + type: object + description: Факт изменения параметров земной станции. Payload содержит только + идентификатор станции. + required: + - stationId + properties: + stationId: + type: integer + format: int64 + description: Идентификатор земной станции. + additionalProperties: false + SpacecraftChangedPayload: + type: object + description: Факт изменения параметров КА/НУ/БЛС. Payload содержит только идентификатор + аппарата. + required: + - spacecraftId + properties: + spacecraftId: + type: integer + format: int64 + description: Идентификатор космического аппарата. + additionalProperties: false diff --git a/services/pcp-request-service/docs/ICD_PCP_final.docx b/services/pcp-request-service/docs/ICD_PCP_final.docx new file mode 100644 index 0000000..78d3eb1 Binary files /dev/null and b/services/pcp-request-service/docs/ICD_PCP_final.docx differ diff --git a/services/pcp-request-service/docs/OpenAPIRequestPCP.yaml b/services/pcp-request-service/docs/OpenAPIRequestPCP.yaml new file mode 100644 index 0000000..c9a3f89 --- /dev/null +++ b/services/pcp-request-service/docs/OpenAPIRequestPCP.yaml @@ -0,0 +1,1378 @@ +openapi: 3.0.3 +info: + title: API заявок pcp-request-service + description: | + Целевой API сервиса pcp-request-service. + + Сервис является владельцем заявок на съемку. API построен вокруг ресурса Request. + + Основные решения: + - заявка создается через POST /v1/requests; + - чтение одной заявки выполняется через GET /v1/requests/{id}; + - чтение списка заявок выполняется через GET /v1/requests; + - удаление заявки выполняется через DELETE /v1/requests/{id}; + - удаление является мягким: заявка получает status = DELETED и deletedAt; + - заявка должна содержать хотя бы один payload-блок: optics или rsa; + - заявка может содержать одновременно optics и rsa; + - surveyType не принимается от клиента, а вычисляется сервисом по наличию optics/rsa; + - deleted-заявки не возвращаются в списке по умолчанию. + version: 1.0.0 +servers: + - url: http://localhost:8080 + description: Локальный сервер разработки +tags: + - name: Requests + description: Операции с заявками + - name: Cells + description: Операции с ячейками сетки + - name: Grid + description: Операции с настройками и перестроением сетки +paths: + /v1/cells: + get: + tags: + - Cells + summary: Получить ячейки сетки + description: | + Возвращает постраничный список ячеек сетки, отсортированных и отфильтрованных по важности. + operationId: listCells + parameters: + - name: minImportance + in: query + required: false + description: Минимальная важность ячейки + schema: + type: number + format: double + minimum: 0 + - name: countLat + in: query + required: false + description: Размер агрегации ячеек по широте + schema: + type: integer + minimum: 1 + - name: countLong + in: query + required: false + description: Размер агрегации ячеек по долготе + schema: + type: integer + minimum: 1 + - name: page + in: query + required: false + description: Номер страницы, начиная с 0 + schema: + type: integer + minimum: 0 + default: 0 + - name: size + in: query + required: false + description: Размер страницы + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: sort + in: query + required: false + description: Сортировка в формате field,direction. Разрешенные поля зависят от реализации. + schema: + type: string + examples: + importanceDesc: + value: importance,desc + cellNumAsc: + value: cellNum,asc + responses: + '200': + description: Список ячеек сетки + content: + application/json: + schema: + $ref: '#/components/schemas/CellsListResponse' + '400': + description: Некорректные query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/cells/with-requests: + get: + tags: + - Cells + summary: Получить ячейки сетки с фрагментами заявок + description: | + Возвращает список ячеек сетки вместе с фрагментами заявок, пересекающими каждую ячейку. + operationId: listCellsWithRequests + parameters: + - name: minImportance + in: query + required: false + description: Минимальная важность ячейки + schema: + type: number + format: double + minimum: 0 + - name: countLat + in: query + required: false + description: Размер агрегации ячеек по широте + schema: + type: integer + minimum: 1 + - name: countLong + in: query + required: false + description: Размер агрегации ячеек по долготе + schema: + type: integer + minimum: 1 + responses: + '200': + description: Ячейки сетки с фрагментами заявок + content: + application/json: + schema: + $ref: '#/components/schemas/CellsWithRequestsResponse' + '400': + description: Некорректные query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/requests: + post: + tags: + - Requests + summary: Создать заявку + description: | + Создает заявку в каноническом формате pcp-request-service. + + Правила: + - id обязателен и должен быть уникальным; + - geometry обязателен и передается в WKT; + - beginDateTime и endDateTime обязательны; + - beginDateTime должен быть раньше endDateTime; + - должен быть указан хотя бы один блок: optics или rsa; + - если указан только optics, surveyType = OPTICS; + - если указан только rsa, surveyType = RSA; + - если указаны optics и rsa, surveyType = COMBINED; + - surveyType не передается клиентом. + operationId: createRequest + requestBody: + required: true + description: Данные создаваемой заявки + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRequestRequest' + examples: + opticsRequest: + summary: Заявка только на оптическую съемку + value: + id: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: Тест1 + geometry: POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5)) + importance: 10.0 + beginDateTime: '2026-01-01T00:00:00Z' + endDateTime: '2026-01-31T23:59:59Z' + kpp: [1, 2, 3] + highPriorityTransmit: false + optics: + resultType: PANCHROMATIC + resolution: 1.0 + sunAngleMin: 10.0 + sunAngleMax: 45.0 + clouds: 90.0 + rsaRequest: + summary: Заявка только на РСА-съемку + value: + id: 550e8400-e29b-41d4-a716-446655440000 + name: Тест1 + geometry: POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0)) + importance: 5.0 + beginDateTime: '2026-02-01T00:00:00Z' + endDateTime: '2026-02-28T23:59:59Z' + kpp: [1] + highPriorityTransmit: true + rsa: + resultType: SPOTLIGHT + interferometry: false + polarisation: HH + resolution: 3.0 + combinedRequest: + summary: Заявка с оптической и РСА-съемкой + value: + id: 7c9e6679-7425-40de-944b-e07fc1f90ae7 + name: Тест1 + geometry: POLYGON ((30 50, 40 50, 40 60, 30 60, 30 50)) + importance: 7.5 + beginDateTime: '2026-03-01T00:00:00Z' + endDateTime: '2026-03-31T23:59:59Z' + kpp: [1, 2] + highPriorityTransmit: false + optics: + resultType: PANCHROMATIC + resolution: 0.5 + rsa: + resultType: SPOTLIGHT + polarisation: HH + resolution: 1.0 + responses: + '201': + description: Заявка создана + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRequestResponse' + example: + id: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: Тест1 + status: ACCEPTED + surveyType: OPTICS + importance: 10.0 + message: Заявка успешно создана + '200': + description: Идемпотентный повтор создания с тем же id и идентичным payload + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRequestResponse' + example: + id: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: Тест1 + status: ACCEPTED + surveyType: OPTICS + importance: 10.0 + message: Заявка уже существует, payload идентичен + '400': + description: Ошибка валидации запроса + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + missingSurveyPayload: + summary: Не указан тип съемки + value: + code: VALIDATION_ERROR + message: 'Должен быть указан хотя бы один блок съемки: optics или rsa' + invalidTimeWindow: + summary: Некорректное временное окно + value: + code: VALIDATION_ERROR + message: beginDateTime должен быть раньше endDateTime + '409': + description: Заявка с таким id уже существует, но payload отличается + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: REQUEST_ALREADY_EXISTS + message: Заявка с таким id уже существует с другим payload + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: INTERNAL_ERROR + message: Внутренняя ошибка при создании заявки + get: + tags: + - Requests + summary: Получить список заявок + description: | + Возвращает постраничный список заявок. + + По умолчанию удаленные заявки не возвращаются. Для технического поиска можно передать includeDeleted=true. + Полная geometry не возвращается в списке по умолчанию; для полной заявки используйте GET /v1/requests/{id}. + operationId: listRequests + parameters: + - name: status + in: query + required: false + description: Фильтр по статусу заявки + schema: + $ref: '#/components/schemas/RequestStatus' + - name: surveyType + in: query + required: false + description: Фильтр по вычисленному типу заявки + schema: + $ref: '#/components/schemas/SurveyType' + - name: kpp + in: query + required: false + description: Фильтр по номеру КПП. Возвращает заявки, содержащие указанный КПП. + schema: + type: integer + example: 1 + - name: highPriorityTransmit + in: query + required: false + description: Фильтр по признаку высокоприоритетной передачи + schema: + type: boolean + - name: beginFrom + in: query + required: false + description: Нижняя граница beginDateTime, включительно + schema: + type: string + format: date-time + - name: beginTo + in: query + required: false + description: Верхняя граница beginDateTime, включительно + schema: + type: string + format: date-time + - name: endFrom + in: query + required: false + description: Нижняя граница endDateTime, включительно + schema: + type: string + format: date-time + - name: endTo + in: query + required: false + description: Верхняя граница endDateTime, включительно + schema: + type: string + format: date-time + - name: includeDeleted + in: query + required: false + description: Включать soft-deleted заявки в результат + schema: + type: boolean + default: false + - name: page + in: query + required: false + description: Номер страницы, начиная с 0 + schema: + type: integer + minimum: 0 + default: 0 + - name: size + in: query + required: false + description: Размер страницы + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: sort + in: query + required: false + description: Сортировка в формате field,direction. Разрешенные поля зависят от реализации. + schema: + type: string + default: createdAt,desc + examples: + createdDesc: + value: createdAt,desc + beginAsc: + value: beginDateTime,asc + responses: + '200': + description: Список заявок + content: + application/json: + schema: + $ref: '#/components/schemas/RequestListResponse' + '400': + description: Некорректные query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/requests/{requestId}/with-cells: + get: + tags: + - Cells + - Requests + summary: Получить заявку с привязанными ячейками + description: | + Возвращает заявку и проекцию ее пересечений с ячейками сетки. + operationId: getRequestWithCells + parameters: + - $ref: '#/components/parameters/RequestIdWithCellsPathParam' + responses: + '200': + description: Заявка с привязанными ячейками + content: + application/json: + schema: + $ref: '#/components/schemas/RequestWithCellsResponse' + '404': + description: Заявка не найдена + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: REQUEST_NOT_FOUND + message: Заявка не найдена + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/requests/{id}: + get: + tags: + - Requests + summary: Получить одну заявку + description: Возвращает полную read-модель заявки, включая geometry, optics и rsa. + operationId: getRequestById + parameters: + - $ref: '#/components/parameters/RequestIdPathParam' + responses: + '200': + description: Заявка найдена + content: + application/json: + schema: + $ref: '#/components/schemas/RequestResponse' + '404': + description: Заявка не найдена + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: REQUEST_NOT_FOUND + message: Заявка не найдена + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Requests + summary: Мягко удалить заявку + description: | + Выполняет soft delete заявки. + + Заявка не удаляется физически. Сервис выставляет status = DELETED и deletedAt. + Повторный DELETE для уже удаленной заявки считается идемпотентным и возвращает текущий статус DELETED. + operationId: deleteRequest + parameters: + - $ref: '#/components/parameters/RequestIdPathParam' + responses: + '200': + description: Заявка удалена или уже была удалена ранее + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteRequestResponse' + example: + id: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + status: DELETED + deletedAt: '2026-05-19T13:00:00Z' + '404': + description: Заявка не найдена + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + code: REQUEST_NOT_FOUND + message: Заявка не найдена + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/grid/settings: + get: + tags: + - Grid + summary: Получить настройки сетки + description: Возвращает текущие параметры сетки. + operationId: getGridSettings + responses: + '200': + description: Текущие настройки сетки + content: + application/json: + schema: + $ref: '#/components/schemas/GridSettingsResponse' + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Grid + summary: Обновить настройки сетки + description: | + Обновляет параметры сетки. + + Endpoint не выполняет автоматическое перестроение сетки. Для явного перестроения используйте POST /v1/grid/rebuild. + operationId: updateGridSettings + requestBody: + required: true + description: Новые настройки сетки + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateGridSettingsRequest' + responses: + '200': + description: Настройки сетки обновлены + content: + application/json: + schema: + $ref: '#/components/schemas/GridSettingsResponse' + '400': + description: Ошибка валидации запроса + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/grid/rebuild: + post: + tags: + - Grid + summary: Перестроить сетку + description: Явно пересоздает сетку и пересобирает request_cells для активных заявок. + operationId: rebuildGrid + responses: + '200': + description: Сетка и проекции активных заявок перестроены + content: + application/json: + schema: + $ref: '#/components/schemas/GridRebuildResponse' + '409': + description: Перестроение сетки уже выполняется + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Внутренняя ошибка сервера + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + parameters: + RequestIdPathParam: + name: id + in: path + required: true + description: Идентификатор заявки + schema: + type: string + format: uuid + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + RequestIdWithCellsPathParam: + name: requestId + in: path + required: true + description: Идентификатор заявки + schema: + type: string + format: uuid + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + schemas: + CreateRequestRequest: + type: object + description: Данные для создания заявки. + required: + - id + - name + - geometry + - importance + - beginDateTime + - endDateTime + properties: + id: + type: string + format: uuid + description: Внешний идентификатор заявки + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: + type: string + minLength: 1 + maxLength: 255 + description: Наименование задания. + example: Тест1 + geometry: + type: string + format: wkt + description: | + Геометрия заявки в формате WKT. + + Целевой поддерживаемый тип: Polygon. + Поддержку MultiPolygon нужно включать отдельно, если она подтверждена на уровне grid-логики. + example: POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5)) + importance: + type: number + format: double + minimum: 0 + description: Важность заявки, используемая при расчете важности ячеек сетки. + example: 10.0 + beginDateTime: + type: string + format: date-time + description: Дата-время начала выполнения заявки + example: '2026-01-01T00:00:00Z' + endDateTime: + type: string + format: date-time + description: Дата-время окончания выполнения заявки + example: '2026-01-31T23:59:59Z' + kpp: + type: array + description: | + Список номеров КПП для сброса. + Если не указан, сброс производится на все доступные КПП. + items: + type: integer + default: [] + example: [1, 2, 3] + highPriorityTransmit: + type: boolean + description: Признак высокоприоритетной передачи + default: false + example: false + optics: + $ref: '#/components/schemas/OpticsParams' + rsa: + $ref: '#/components/schemas/RsaParams' + additionalProperties: false + anyOf: + - required: + - optics + - required: + - rsa + CreateRequestResponse: + type: object + description: Ответ на создание заявки + required: + - id + - name + - status + - surveyType + - importance + properties: + id: + type: string + format: uuid + description: Идентификатор заявки + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: + type: string + minLength: 1 + maxLength: 255 + description: Наименование задания. + example: Тест1 + status: + $ref: '#/components/schemas/RequestStatus' + surveyType: + $ref: '#/components/schemas/SurveyType' + importance: + type: number + format: double + minimum: 0 + description: Важность заявки, используемая при расчете важности ячеек сетки. + example: 10.0 + message: + type: string + description: Дополнительное сообщение + example: Заявка успешно создана + RequestResponse: + type: object + description: Полная read-модель заявки + required: + - id + - name + - status + - surveyType + - geometry + - importance + - beginDateTime + - endDateTime + - highPriorityTransmit + - coverage + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + description: Идентификатор заявки + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: + type: string + minLength: 1 + maxLength: 255 + description: Наименование задания. + example: Тест1 + status: + $ref: '#/components/schemas/RequestStatus' + surveyType: + $ref: '#/components/schemas/SurveyType' + geometry: + type: string + format: wkt + description: Геометрия заявки в формате WKT + example: POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5)) + importance: + type: number + format: double + minimum: 0 + description: Важность заявки, используемая при расчете важности ячеек сетки. + example: 10.0 + beginDateTime: + type: string + format: date-time + example: '2026-01-01T00:00:00Z' + endDateTime: + type: string + format: date-time + example: '2026-01-31T23:59:59Z' + kpp: + type: array + items: + type: integer + description: Список номеров КПП для сброса + example: [1, 2, 3] + highPriorityTransmit: + type: boolean + description: Признак высокоприоритетной передачи + example: false + optics: + allOf: + - $ref: '#/components/schemas/OpticsParams' + nullable: true + rsa: + allOf: + - $ref: '#/components/schemas/RsaParams' + nullable: true + coverage: + $ref: '#/components/schemas/CoverageState' + createdAt: + type: string + format: date-time + description: Дата-время создания заявки + example: '2026-05-19T12:00:00Z' + updatedAt: + type: string + format: date-time + description: Дата-время последнего обновления заявки + example: '2026-05-19T12:30:00Z' + deletedAt: + type: string + format: date-time + nullable: true + description: Дата-время soft delete. Null, если заявка не удалена. + example: null + additionalProperties: false + RequestSummaryResponse: + type: object + description: Краткая карточка заявки для list endpoint + required: + - id + - name + - status + - surveyType + - importance + - beginDateTime + - endDateTime + - highPriorityTransmit + - coveragePercent + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + description: Идентификатор заявки + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: + type: string + minLength: 1 + maxLength: 255 + description: Наименование задания. + example: Тест1 + status: + $ref: '#/components/schemas/RequestStatus' + surveyType: + $ref: '#/components/schemas/SurveyType' + importance: + type: number + format: double + minimum: 0 + description: Важность заявки, используемая при расчете важности ячеек сетки. + example: 10.0 + beginDateTime: + type: string + format: date-time + example: '2026-01-01T00:00:00Z' + endDateTime: + type: string + format: date-time + example: '2026-01-31T23:59:59Z' + kpp: + type: array + items: + type: integer + description: Список номеров КПП для сброса + example: [1, 2, 3] + highPriorityTransmit: + type: boolean + example: false + coveragePercent: + type: number + format: double + minimum: 0 + maximum: 100 + description: Текущий процент покрытия заявки + example: 35.5 + createdAt: + type: string + format: date-time + example: '2026-05-19T12:00:00Z' + updatedAt: + type: string + format: date-time + example: '2026-05-19T12:30:00Z' + deletedAt: + type: string + format: date-time + nullable: true + description: Дата-время soft delete. Обычно отсутствует/null при includeDeleted=false. + example: null + additionalProperties: false + RequestListResponse: + type: object + description: Постраничный список заявок + required: + - items + - page + - size + - totalItems + - totalPages + properties: + items: + type: array + items: + $ref: '#/components/schemas/RequestSummaryResponse' + page: + type: integer + minimum: 0 + example: 0 + size: + type: integer + minimum: 1 + example: 50 + totalItems: + type: integer + format: int64 + minimum: 0 + example: 1 + totalPages: + type: integer + minimum: 0 + example: 1 + additionalProperties: false + DeleteRequestResponse: + type: object + description: Ответ на soft delete заявки + required: + - id + - status + - deletedAt + properties: + id: + type: string + format: uuid + description: Идентификатор заявки + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + status: + type: string + enum: + - DELETED + description: Итоговый статус после soft delete + example: DELETED + deletedAt: + type: string + format: date-time + description: Дата-время soft delete + example: '2026-05-19T13:00:00Z' + additionalProperties: false + CellSummaryResponse: + type: object + description: Краткая карточка ячейки сетки + required: + - cellNum + - latitude + - longitude + - importance + - contour + properties: + cellNum: + type: integer + format: int64 + description: Номер ячейки сетки + example: 32400 + latitude: + type: number + format: double + description: Широта центра ячейки + example: 55.0 + longitude: + type: number + format: double + description: Долгота центра ячейки + example: 37.0 + importance: + type: number + format: double + minimum: 0 + description: Расчетная важность ячейки + example: 10.0 + contour: + type: string + format: wkt + description: Контур ячейки в формате WKT + example: POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55)) + additionalProperties: false + CellsListResponse: + type: object + description: Постраничный список ячеек сетки + required: + - items + - page + - size + - totalItems + - totalPages + properties: + items: + type: array + items: + $ref: '#/components/schemas/CellSummaryResponse' + page: + type: integer + minimum: 0 + example: 0 + size: + type: integer + minimum: 1 + example: 50 + totalItems: + type: integer + format: int64 + minimum: 0 + example: 1 + totalPages: + type: integer + minimum: 0 + example: 1 + additionalProperties: false + CellWithRequestsResponse: + type: object + description: Ячейка сетки с фрагментами заявок + required: + - cellNum + - latitude + - longitude + - importance + - contour + - requests + properties: + cellNum: + type: integer + format: int64 + description: Номер ячейки сетки + example: 32400 + latitude: + type: number + format: double + description: Широта центра ячейки + example: 55.0 + longitude: + type: number + format: double + description: Долгота центра ячейки + example: 37.0 + importance: + type: number + format: double + minimum: 0 + description: Расчетная важность ячейки + example: 10.0 + contour: + type: string + format: wkt + description: Контур ячейки в формате WKT + example: POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55)) + requests: + type: array + description: Фрагменты заявок, пересекающие ячейку + items: + $ref: '#/components/schemas/CellRequestFragmentResponse' + additionalProperties: false + CellRequestFragmentResponse: + type: object + description: Фрагмент заявки внутри ячейки сетки + required: + - requestId + - contour + - coveragePercent + - importance + properties: + requestId: + type: string + format: uuid + description: Идентификатор заявки + example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + contour: + type: string + format: wkt + description: Контур пересечения заявки с ячейкой в формате WKT + example: POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5)) + coveragePercent: + type: number + format: double + minimum: 0 + maximum: 100 + description: Процент покрытия ячейки фрагментом заявки + example: 25.0 + importance: + type: number + format: double + minimum: 0 + description: Важность заявки для расчета важности ячейки + example: 10.0 + additionalProperties: false + CellsWithRequestsResponse: + type: object + description: Список ячеек сетки с фрагментами заявок + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/CellWithRequestsResponse' + additionalProperties: false + RequestWithCellsResponse: + type: object + description: Заявка с проекциями на ячейки сетки + required: + - request + - cells + properties: + request: + $ref: '#/components/schemas/RequestResponse' + cells: + type: array + items: + $ref: '#/components/schemas/RequestCellResponse' + additionalProperties: false + RequestCellResponse: + type: object + description: Ячейка, связанная с заявкой + required: + - cellNum + - coveragePercent + - importance + - contour + properties: + cellNum: + type: integer + format: int64 + description: Номер ячейки сетки + example: 32400 + coveragePercent: + type: number + format: double + minimum: 0 + maximum: 100 + description: Процент покрытия ячейки заявкой + example: 25.0 + importance: + type: number + format: double + minimum: 0 + description: Важность заявки для расчета важности ячейки + example: 10.0 + contour: + type: string + format: wkt + description: Контур пересечения заявки с ячейкой в формате WKT + example: POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5)) + additionalProperties: false + GridSettingsResponse: + type: object + description: Текущие настройки сетки + required: + - settingsId + - calculationStep + properties: + settingsId: + type: integer + format: int64 + description: Идентификатор набора настроек сетки + example: 1 + calculationStep: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + description: Шаг построения сетки в градусах + example: 2.0 + additionalProperties: false + UpdateGridSettingsRequest: + type: object + description: Обновляемые настройки сетки + required: + - calculationStep + properties: + calculationStep: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + description: Новый шаг построения сетки в градусах. Обновление не запускает rebuild автоматически. + example: 2.0 + additionalProperties: false + GridRebuildResponse: + type: object + description: Результат явного перестроения сетки + required: + - cellsCount + - rebuiltRequestProjectionsCount + - rebuiltAt + properties: + cellsCount: + type: integer + format: int64 + minimum: 0 + description: Количество ячеек после перестроения сетки + example: 16200 + rebuiltRequestProjectionsCount: + type: integer + format: int64 + minimum: 0 + description: Количество пересобранных проекций активных заявок в request_cells + example: 1250 + rebuiltAt: + type: string + format: date-time + description: Дата-время завершения перестроения + example: '2026-05-20T10:15:30Z' + additionalProperties: false + CoverageState: + type: object + description: Состояние покрытия заявки + required: + - currentPercent + properties: + requiredPercent: + type: number + format: double + minimum: 0 + maximum: 100 + nullable: true + description: Требуемый процент покрытия + example: 100.0 + currentPercent: + type: number + format: double + minimum: 0 + maximum: 100 + description: Текущий процент покрытия + example: 35.5 + additionalProperties: false + OpticsParams: + type: object + required: + - resultType + - resolution + description: Параметры оптической съемки + properties: + resultType: + type: string + description: Результат оптической съемки + enum: + - PANCHROMATIC + - MULTISPECTRAL + - PANSHARPENING + default: PANCHROMATIC + example: PANCHROMATIC + resolution: + type: number + format: double + description: Требуемое разрешение съемки, метры + minimum: 0.1 + example: 1.0 + sunAngleMin: + type: number + format: double + description: Минимальная высота Солнца, градусы + minimum: 0 + maximum: 90 + default: 10.0 + example: 10.0 + sunAngleMax: + type: number + format: double + description: Максимальная высота Солнца, градусы + minimum: 0 + maximum: 90 + default: 90.0 + example: 90.0 + clouds: + type: number + format: double + description: Максимально допустимая облачность, проценты + minimum: 0 + maximum: 100 + default: 100.0 + example: 90.0 + additionalProperties: false + RsaParams: + type: object + required: + - resultType + - resolution + - polarisation + description: Параметры радиолокационной съемки РСА + properties: + resultType: + type: string + description: Тип аппаратуры РСА + enum: + - SPOTLIGHT + - STRIPMAP + - SCANSAR + example: SPOTLIGHT + interferometry: + type: boolean + description: Признак интерферометрии + default: false + example: true + polarisation: + type: string + description: Тип поляризации + enum: + - HH + - VV + example: HH + resolution: + type: number + format: double + description: Требуемое разрешение съемки, метры + minimum: 0.1 + example: 3.0 + additionalProperties: false + RequestStatus: + type: string + description: Доменный статус заявки + enum: + - ACCEPTED + - ACTIVE + - COMPLETED + - EXPIRED + - DELETED + x-enum-descriptions: + - Заявка прошла валидацию и сохранена. + - Заявка актуальна для обработки. + - Заявка выполнена. + - Временное окно заявки истекло. + - Заявка мягко удалена. + SurveyType: + type: string + description: Вычисленный тип заявки по наличию optics/rsa + enum: + - OPTICS + - RSA + - COMBINED + x-enum-descriptions: + - В заявке указан только optics. + - В заявке указан только rsa. + - В заявке указаны optics и rsa. + ErrorResponse: + type: object + description: Ошибка API + required: + - code + - message + properties: + code: + type: string + description: Машиночитаемый код ошибки + example: VALIDATION_ERROR + message: + type: string + description: Человекочитаемое описание ошибки + example: 'Должен быть указан хотя бы один блок съемки: optics или rsa' + details: + type: array + description: Детали ошибки по полям + items: + $ref: '#/components/schemas/ErrorDetail' + additionalProperties: false + ErrorDetail: + type: object + description: Деталь ошибки валидации + required: + - field + - message + properties: + field: + type: string + description: Поле, к которому относится ошибка + example: optics + message: + type: string + description: Описание ошибки поля + example: 'Должен быть указан хотя бы один блок съемки: optics или rsa' + additionalProperties: false diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/PcpRequestServiceApplication.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/PcpRequestServiceApplication.kt new file mode 100644 index 0000000..ba9c09c --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/PcpRequestServiceApplication.kt @@ -0,0 +1,15 @@ +package org.nstart.dep265.requestservice + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling +class PcpRequestServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/JacksonConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/JacksonConfig.kt new file mode 100644 index 0000000..3d796ec --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/JacksonConfig.kt @@ -0,0 +1,22 @@ +package org.nstart.dep265.requestservice.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JacksonConfig { + /** + * The service code still depends on Jackson 2 APIs, while the shared Boot 4 stack + * auto-configures Jackson 3 for MVC. Keep a dedicated Jackson 2 mapper for internal + * parsing until the module is migrated. + */ + @Bean + fun objectMapper(): ObjectMapper { + return jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules() + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt new file mode 100644 index 0000000..78f6747 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt @@ -0,0 +1,34 @@ +package org.nstart.dep265.requestservice.config + +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory + +@Configuration +class KafkaProducerConfig { + @Bean + @ConditionalOnMissingBean + fun requestProducerFactory( + @Value("\${spring.kafka.bootstrap-servers:192.168.100.160:19092}") bootstrapServers: String, + ): ProducerFactory { + return DefaultKafkaProducerFactory( + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ), + ) + } + + @Bean + @ConditionalOnMissingBean + fun requestKafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { + return KafkaTemplate(producerFactory) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OpenApiConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OpenApiConfig.kt new file mode 100644 index 0000000..d89fcb8 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OpenApiConfig.kt @@ -0,0 +1,20 @@ +package org.nstart.dep265.requestservice.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + @Bean + fun requestServiceOpenApi(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("PCP Request Service API") + .description("REST API сервиса заявок PCP.") + .version("v1"), + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt new file mode 100644 index 0000000..2bcd7d9 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt @@ -0,0 +1,10 @@ +package org.nstart.dep265.requestservice.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "pcp.outbox") +data class OutboxProperties( + val requestCompletedTopic: String = "pcp.request.completed.v1", + val publishBatchSize: Int = 50, + val publishFixedDelayMs: Long = 5_000, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt new file mode 100644 index 0000000..f4d6de2 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt @@ -0,0 +1,25 @@ +package org.nstart.dep265.requestservice.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionOperations +import org.springframework.transaction.support.TransactionTemplate + +@Configuration +class OutboxTransactionConfig { + @Bean("routeMatchingTransactionOperations") + fun routeMatchingTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations { + return TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRED + } + } + + @Bean("outboxTransactionOperations") + fun outboxTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations { + return TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsApiExceptionHandler.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsApiExceptionHandler.kt new file mode 100644 index 0000000..e2ca423 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsApiExceptionHandler.kt @@ -0,0 +1,33 @@ +package org.nstart.dep265.requestservice.controller + +import org.nstart.dep265.requestservice.dto.ErrorResponseDto +import org.nstart.dep265.requestservice.service.InvalidCellsQueryException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +@RestControllerAdvice(assignableTypes = [CellsController::class]) +class CellsApiExceptionHandler { + @ExceptionHandler(InvalidCellsQueryException::class) + fun handleInvalidCellsQuery(exception: InvalidCellsQueryException): ResponseEntity { + return validationError(exception.message ?: "Некорректные query parameters") + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity { + return validationError("Некорректное значение query parameter: ${exception.name}") + } + + private fun validationError(message: String): ResponseEntity { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body( + ErrorResponseDto( + code = "VALIDATION_ERROR", + message = message, + ), + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt new file mode 100644 index 0000000..6843806 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt @@ -0,0 +1,47 @@ +package org.nstart.dep265.requestservice.controller + +import org.nstart.dep265.requestservice.dto.CellsListResponseDto +import org.nstart.dep265.requestservice.dto.CellsWithRequestsResponseDto +import org.nstart.dep265.requestservice.service.CellsQueryService +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 + +@RestController +@RequestMapping("/v1/cells") +class CellsController( + private val cellsQueryService: CellsQueryService, +) { + @GetMapping + fun listCells( + @RequestParam(required = false) minImportance: Double?, + @RequestParam(required = false) countLat: Int?, + @RequestParam(required = false) countLong: Int?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "50") size: Int, + @RequestParam(required = false) sort: String?, + ): CellsListResponseDto { + return cellsQueryService.listCells( + minImportance = minImportance, + countLat = countLat, + countLong = countLong, + page = page, + size = size, + sort = sort, + ) + } + + @GetMapping("/with-requests") + fun listCellsWithRequests( + @RequestParam(required = false) minImportance: Double?, + @RequestParam(required = false) countLat: Int?, + @RequestParam(required = false) countLong: Int?, + ): CellsWithRequestsResponseDto { + return cellsQueryService.listCellsWithRequests( + minImportance = minImportance, + countLat = countLat, + countLong = countLong, + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementApiExceptionHandler.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementApiExceptionHandler.kt new file mode 100644 index 0000000..ffea806 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementApiExceptionHandler.kt @@ -0,0 +1,63 @@ +package org.nstart.dep265.requestservice.controller + +import org.nstart.dep265.requestservice.dto.ErrorDetailDto +import org.nstart.dep265.requestservice.dto.ErrorResponseDto +import org.nstart.dep265.requestservice.service.GridRebuildAlreadyRunningException +import org.nstart.dep265.requestservice.service.InvalidGridSettingsException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(assignableTypes = [GridManagementController::class]) +class GridManagementApiExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationError(exception: MethodArgumentNotValidException): ResponseEntity { + val details = exception.bindingResult.fieldErrors.map { error -> + ErrorDetailDto( + field = error.field, + message = error.defaultMessage ?: "Некорректное значение", + ) + } + return validationError("Ошибка валидации запроса", details) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleNotReadable(exception: HttpMessageNotReadableException): ResponseEntity { + return validationError(exception.mostSpecificCause.message ?: "Некорректный JSON-запрос") + } + + @ExceptionHandler(InvalidGridSettingsException::class) + fun handleInvalidGridSettings(exception: InvalidGridSettingsException): ResponseEntity { + return validationError(exception.message ?: "Некорректные настройки сетки") + } + + @ExceptionHandler(GridRebuildAlreadyRunningException::class) + fun handleGridRebuildAlreadyRunning(exception: GridRebuildAlreadyRunningException): ResponseEntity { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body( + ErrorResponseDto( + code = "GRID_REBUILD_ALREADY_RUNNING", + message = exception.message ?: "Grid rebuild is already running", + ), + ) + } + + private fun validationError( + message: String, + details: List? = null, + ): ResponseEntity { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body( + ErrorResponseDto( + code = "VALIDATION_ERROR", + message = message, + details = details, + ), + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt new file mode 100644 index 0000000..8939389 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt @@ -0,0 +1,38 @@ +package org.nstart.dep265.requestservice.controller + +import jakarta.validation.Valid +import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto +import org.nstart.dep265.requestservice.dto.GridSettingsResponseDto +import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto +import org.nstart.dep265.requestservice.service.GridRebuildService +import org.nstart.dep265.requestservice.service.GridSettingsService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/v1/grid") +class GridManagementController( + private val gridSettingsService: GridSettingsService, + private val gridRebuildService: GridRebuildService, +) { + @GetMapping("/settings") + fun getGridSettings(): GridSettingsResponseDto { + return gridSettingsService.getSettings() + } + + @PutMapping("/settings") + fun updateGridSettings( + @Valid @RequestBody request: UpdateGridSettingsRequestDto, + ): GridSettingsResponseDto { + return gridSettingsService.updateSettings(request) + } + + @PostMapping("/rebuild") + fun rebuildGrid(): GridRebuildResponseDto { + return gridRebuildService.rebuildGrid() + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestApiExceptionHandler.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestApiExceptionHandler.kt new file mode 100644 index 0000000..adeb0c2 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestApiExceptionHandler.kt @@ -0,0 +1,70 @@ +package org.nstart.dep265.requestservice.controller + +import org.nstart.dep265.requestservice.dto.ErrorDetailDto +import org.nstart.dep265.requestservice.dto.ErrorResponseDto +import org.nstart.dep265.requestservice.service.InvalidRequestListSortException +import org.nstart.dep265.requestservice.service.RequestAlreadyExistsException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(assignableTypes = [RequestController::class]) +class RequestApiExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationError(exception: MethodArgumentNotValidException): ResponseEntity { + val details = exception.bindingResult.fieldErrors.map { error -> + ErrorDetailDto( + field = error.field, + message = error.defaultMessage ?: "Некорректное значение", + ) + } + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body( + ErrorResponseDto( + code = "VALIDATION_ERROR", + message = "Ошибка валидации запроса", + details = details, + ), + ) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleNotReadable(exception: HttpMessageNotReadableException): ResponseEntity { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body( + ErrorResponseDto( + code = "VALIDATION_ERROR", + message = exception.mostSpecificCause.message ?: "Некорректный JSON-запрос", + ), + ) + } + + @ExceptionHandler(RequestAlreadyExistsException::class) + fun handleRequestAlreadyExists(exception: RequestAlreadyExistsException): ResponseEntity { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body( + ErrorResponseDto( + code = "REQUEST_ALREADY_EXISTS", + message = "Заявка с таким id уже существует", + ), + ) + } + + @ExceptionHandler(InvalidRequestListSortException::class) + fun handleInvalidRequestListSort(exception: InvalidRequestListSortException): ResponseEntity { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body( + ErrorResponseDto( + code = "VALIDATION_ERROR", + message = exception.message ?: "Некорректный параметр sort", + ), + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt new file mode 100644 index 0000000..37e05a1 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt @@ -0,0 +1,89 @@ +package org.nstart.dep265.requestservice.controller + +import jakarta.validation.Valid +import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto +import org.nstart.dep265.requestservice.dto.CreateRequestResponseDto +import org.nstart.dep265.requestservice.dto.DeleteRequestResponseDto +import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto +import org.nstart.dep265.requestservice.dto.RequestListResponseDto +import org.nstart.dep265.requestservice.dto.RequestResponseDto +import org.nstart.dep265.requestservice.dto.RequestStatusDto +import org.nstart.dep265.requestservice.dto.RequestWithCellsResponseDto +import org.nstart.dep265.requestservice.dto.SurveyTypeDto +import org.nstart.dep265.requestservice.service.RequestService +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/v1/requests") +class RequestController( + private val requestService: RequestService, +) { + @PostMapping + fun createRequest(@Valid @RequestBody request: CreateRequestRequestDto): ResponseEntity { + return ResponseEntity.status(201).body(requestService.createRequest(request)) + } + + @GetMapping + fun listRequests( + @RequestParam(required = false) status: RequestStatusDto?, + @RequestParam(required = false) surveyType: SurveyTypeDto?, + @RequestParam(required = false) kpp: Int?, + @RequestParam(required = false) highPriorityTransmit: Boolean?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(defaultValue = "false") includeDeleted: Boolean, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "50") size: Int, + @RequestParam(defaultValue = "createdAt,desc") sort: String, + ): RequestListResponseDto { + val query = ListRequestsQueryDto( + status = status, + surveyType = surveyType, + kpp = kpp, + highPriorityTransmit = highPriorityTransmit, + beginFrom = beginFrom, + beginTo = beginTo, + endFrom = endFrom, + endTo = endTo, + includeDeleted = includeDeleted, + page = page, + size = size, + sort = sort, + ) + return requestService.listRequests(query) + } + + @GetMapping("/{id}") + fun getRequestById(@PathVariable id: UUID): ResponseEntity { + return requestService.getRequestById(id) + ?.let { request -> ResponseEntity.ok(request) } + ?: ResponseEntity.notFound().build() + } + + @GetMapping("/{requestId}/with-cells") + fun getRequestWithCells(@PathVariable requestId: UUID): ResponseEntity { + return requestService.getRequestWithCells(requestId) + ?.let { response -> ResponseEntity.ok(response) } + ?: ResponseEntity.notFound().build() + } + + @DeleteMapping("/{id}") + fun deleteRequest(@PathVariable id: UUID): ResponseEntity { + return requestService.deleteRequest(id) + ?.let { response -> ResponseEntity.ok(response) } + ?: ResponseEntity.notFound().build() + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/CellsApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/CellsApiDtos.kt new file mode 100644 index 0000000..2e2d603 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/CellsApiDtos.kt @@ -0,0 +1,91 @@ +package org.nstart.dep265.requestservice.dto + +import java.util.UUID + +/** + * Краткая карточка ячейки сетки для GET /v1/cells. + */ +data class CellSummaryResponseDto( + /** Номер ячейки сетки. */ + val cellNum: Long, + + /** Широта центра ячейки. */ + val latitude: Double, + + /** Долгота центра ячейки. */ + val longitude: Double, + + /** Расчетная важность ячейки. */ + val importance: Double, + + /** Контур ячейки в WKT. */ + val contour: String, +) + +/** + * Постраничный список ячеек сетки. + */ +data class CellsListResponseDto( + /** Элементы текущей страницы. */ + val items: List, + + /** Номер страницы, начиная с 0. */ + val page: Int, + + /** Размер страницы. */ + val size: Int, + + /** Общее количество элементов. */ + val totalItems: Long, + + /** Общее количество страниц. */ + val totalPages: Int, +) + +/** + * Фрагмент заявки внутри ячейки сетки для GET /v1/cells/with-requests. + */ +data class CellRequestFragmentResponseDto( + /** Идентификатор заявки. */ + val requestId: UUID, + + /** Контур фрагмента заявки внутри ячейки в WKT. */ + val contour: String, + + /** Процент покрытия ячейки фрагментом заявки. */ + val coveragePercent: Double, + + /** Snapshot важности заявки из request_cells. */ + val importance: Double, +) + +/** + * Ячейка сетки с фрагментами заявок для GET /v1/cells/with-requests. + */ +data class CellWithRequestsResponseDto( + /** Номер ячейки сетки. */ + val cellNum: Long, + + /** Широта центра ячейки. */ + val latitude: Double, + + /** Долгота центра ячейки. */ + val longitude: Double, + + /** Расчетная важность ячейки. */ + val importance: Double, + + /** Контур ячейки в WKT. */ + val contour: String, + + /** Фрагменты заявок, пересекающие ячейку. */ + val requests: List, +) + +/** + * Список ячеек сетки с фрагментами заявок. + */ +data class CellsWithRequestsResponseDto( + /** Ячейки сетки с привязанными request_cells fragments. */ + val items: List, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt new file mode 100644 index 0000000..669b84d --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt @@ -0,0 +1,42 @@ +package org.nstart.dep265.requestservice.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.NotNull +import java.time.OffsetDateTime + +/** + * Текущие настройки сетки. + */ +data class GridSettingsResponseDto( + /** Идентификатор набора настроек сетки. */ + val settingsId: Long, + + /** Шаг построения сетки в градусах. */ + val calculationStep: Double, +) + +/** + * Обновляемые настройки сетки. Обновление не запускает rebuild автоматически. + */ +data class UpdateGridSettingsRequestDto( + /** Новый шаг построения сетки в градусах. */ + @field:NotNull + @field:DecimalMin(value = "0.0", inclusive = false) + val calculationStep: Double?, +) : StrictRequestApiDto() + +/** + * Результат явного перестроения сетки и request_cells projection. + */ +data class GridRebuildResponseDto( + /** Количество ячеек earth_cells после перестроения. */ + val cellsCount: Long, + + /** Количество активных заявок, для которых пересобраны request_cells. */ + val rebuiltRequestProjectionsCount: Long, + + /** Дата-время завершения перестроения в UTC. */ + @field:JsonFormat(shape = JsonFormat.Shape.STRING) + val rebuiltAt: OffsetDateTime, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt new file mode 100644 index 0000000..9690b31 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt @@ -0,0 +1,467 @@ +package org.nstart.dep265.requestservice.dto + +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.validation.Valid +import jakarta.validation.constraints.AssertTrue +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import java.time.OffsetDateTime +import java.util.UUID + +/** + * Базовая защита OpenAPI DTO от полей вне schema.additionalProperties=false. + */ +abstract class StrictRequestApiDto { + @JsonAnySetter + fun rejectAdditionalProperty(name: String, value: Any?) { + throw IllegalArgumentException("Unknown request API property: $name") + } +} + +/** + * Тело запроса на создание заявки по OpenAPI. + */ +data class CreateRequestRequestDto( + /** Внешний идентификатор заявки. */ + @field:NotNull + val id: UUID, + + /** Наименование задания. */ + @field:NotBlank + @field:Size(max = 255) + val name: String, + + /** Геометрия заявки в WKT. */ + @field:NotBlank + val geometry: String, + + /** Важность заявки для расчета важности ячеек сетки. */ + @field:NotNull + @field:DecimalMin("0") + val importance: Double?, + + /** Начало временного окна выполнения заявки. */ + @field:NotNull + val beginDateTime: OffsetDateTime, + + /** Окончание временного окна выполнения заявки. */ + @field:NotNull + val endDateTime: OffsetDateTime, + + /** Список номеров КПП для сброса. */ + val kpp: List = emptyList(), + + /** Признак высокоприоритетной передачи. */ + val highPriorityTransmit: Boolean = false, + + /** Параметры оптической съемки. */ + @field:Valid + val optics: OpticsParamsDto? = null, + + /** Параметры радиолокационной съемки РСА. */ + @field:Valid + val rsa: RsaParamsDto? = null, +) : StrictRequestApiDto() { + /** В create-запросе должен быть указан хотя бы один payload-блок съемки. */ + @get:AssertTrue(message = "Должен быть указан хотя бы один блок съемки: optics или rsa") + @get:JsonIgnore + val hasSurveyPayload: Boolean + get() = optics != null || rsa != null +} + +/** + * Ответ на создание заявки. + */ +data class CreateRequestResponseDto( + /** Идентификатор заявки. */ + val id: UUID, + + /** Наименование задания. */ + val name: String, + + /** Статус заявки после приема. */ + val status: RequestStatusDto, + + /** Вычисленный тип заявки по наличию optics/rsa. */ + val surveyType: SurveyTypeDto, + + /** Важность заявки для расчета важности ячеек сетки. */ + val importance: Double, + + /** Дополнительное сообщение для клиента. */ + val message: String? = null, +) + +/** + * Полная read-модель заявки. + */ +data class RequestResponseDto( + /** Идентификатор заявки. */ + val id: UUID, + + /** Наименование задания. */ + val name: String, + + /** Текущий статус заявки. */ + val status: RequestStatusDto, + + /** Вычисленный тип заявки. */ + val surveyType: SurveyTypeDto, + + /** Полная геометрия заявки в WKT. */ + val geometry: String, + + /** Важность заявки для расчета важности ячеек сетки. */ + val importance: Double, + + /** Начало временного окна выполнения заявки. */ + val beginDateTime: OffsetDateTime, + + /** Окончание временного окна выполнения заявки. */ + val endDateTime: OffsetDateTime, + + /** Список номеров КПП для сброса. */ + val kpp: List = emptyList(), + + /** Признак высокоприоритетной передачи. */ + val highPriorityTransmit: Boolean, + + /** Параметры оптической съемки, если заявка содержит optics. */ + val optics: OpticsParamsDto? = null, + + /** Параметры РСА-съемки, если заявка содержит rsa. */ + val rsa: RsaParamsDto? = null, + + /** Текущее состояние покрытия заявки. */ + val coverage: CoverageStateDto, + + /** Время создания заявки. */ + val createdAt: OffsetDateTime, + + /** Время последнего обновления заявки. */ + val updatedAt: OffsetDateTime, + + /** Время soft delete, если заявка удалена. */ + val deletedAt: OffsetDateTime? = null, +) + +/** + * Краткая карточка заявки для list endpoint. + */ +data class RequestSummaryResponseDto( + /** Идентификатор заявки. */ + val id: UUID, + + /** Наименование задания. */ + val name: String, + + /** Текущий статус заявки. */ + val status: RequestStatusDto, + + /** Вычисленный тип заявки. */ + val surveyType: SurveyTypeDto, + + /** Важность заявки для расчета важности ячеек сетки. */ + val importance: Double, + + /** Начало временного окна выполнения заявки. */ + val beginDateTime: OffsetDateTime, + + /** Окончание временного окна выполнения заявки. */ + val endDateTime: OffsetDateTime, + + /** Список номеров КПП для сброса. */ + val kpp: List = emptyList(), + + /** Признак высокоприоритетной передачи. */ + val highPriorityTransmit: Boolean, + + /** Текущий процент покрытия заявки. */ + val coveragePercent: Double, + + /** Время создания заявки. */ + val createdAt: OffsetDateTime, + + /** Время последнего обновления заявки. */ + val updatedAt: OffsetDateTime, + + /** Время soft delete, если заявка удалена. */ + val deletedAt: OffsetDateTime? = null, +) + +/** + * Постраничный ответ list endpoint. + */ +data class RequestListResponseDto( + /** Элементы текущей страницы. */ + val items: List, + + /** Номер страницы, начиная с 0. */ + val page: Int, + + /** Размер страницы. */ + val size: Int, + + /** Общее количество элементов. */ + val totalItems: Long, + + /** Общее количество страниц. */ + val totalPages: Int, +) + +/** + * Ячейка, связанная с заявкой через request_cells projection. + */ +data class RequestCellResponseDto( + /** Номер ячейки сетки из earth_cells. */ + val cellNum: Long, + + /** Процент покрытия ячейки фрагментом заявки из request_cells. */ + val coveragePercent: Double, + + /** Snapshot важности заявки из request_cells. */ + val importance: Double, + + /** Контур фрагмента заявки внутри ячейки в WKT из request_cells. */ + val contour: String, +) + +/** + * Заявка с проекциями на ячейки сетки. + */ +data class RequestWithCellsResponseDto( + /** Полная read-модель заявки нового /v1 API. */ + val request: RequestResponseDto, + + /** Проекции заявки на ячейки сетки из request_cells. */ + val cells: List, +) + +/** + * Ответ на soft delete заявки. + */ +data class DeleteRequestResponseDto( + /** Идентификатор заявки. */ + val id: UUID, + + /** Итоговый статус после удаления. */ + val status: DeleteRequestStatusDto, + + /** Время soft delete. */ + val deletedAt: OffsetDateTime, +) + +/** + * Состояние покрытия заявки. + */ +data class CoverageStateDto( + /** Требуемый процент покрытия, если он задан доменной моделью. */ + val requiredPercent: Double? = null, + + /** Текущий процент покрытия. */ + val currentPercent: Double, +) + +/** + * Параметры оптической съемки. + */ +data class OpticsParamsDto( + /** Результат оптической съемки. */ + @field:NotNull + val resultType: OpticsResultTypeDto, + + /** Требуемое разрешение съемки, метры. */ + @field:DecimalMin("0.1") + val resolution: Double, + + /** Минимальная высота Солнца, градусы. */ + @field:DecimalMin("0") + @field:DecimalMax("90") + val sunAngleMin: Double = 10.0, + + /** Максимальная высота Солнца, градусы. */ + @field:DecimalMin("0") + @field:DecimalMax("90") + val sunAngleMax: Double = 90.0, + + /** Максимально допустимая облачность, проценты. */ + @field:DecimalMin("0") + @field:DecimalMax("100") + val clouds: Double = 100.0, +) : StrictRequestApiDto() + +/** + * Параметры радиолокационной съемки РСА. + */ +data class RsaParamsDto( + /** Тип аппаратуры РСА. */ + @field:NotNull + val resultType: RsaResultTypeDto, + + /** Признак интерферометрии. */ + val interferometry: Boolean = false, + + /** Тип поляризации. */ + @field:NotNull + val polarisation: PolarisationDto, + + /** Требуемое разрешение съемки, метры. */ + @field:DecimalMin("0.1") + val resolution: Double, +) : StrictRequestApiDto() + +/** + * Ошибка API. + */ +data class ErrorResponseDto( + /** Машиночитаемый код ошибки. */ + val code: String, + + /** Человекочитаемое описание ошибки. */ + val message: String, + + /** Детали ошибки по полям. */ + val details: List? = null, +) + +/** + * Деталь ошибки валидации. + */ +data class ErrorDetailDto( + /** Поле, к которому относится ошибка. */ + val field: String, + + /** Описание ошибки поля. */ + val message: String, +) + +/** + * Query parameters list endpoint. + */ +data class ListRequestsQueryDto( + /** Фильтр по статусу заявки. */ + val status: RequestStatusDto? = null, + + /** Фильтр по вычисленному типу заявки. */ + val surveyType: SurveyTypeDto? = null, + + /** Фильтр по номеру КПП. */ + val kpp: Int? = null, + + /** Фильтр по признаку высокоприоритетной передачи. */ + val highPriorityTransmit: Boolean? = null, + + /** Нижняя граница beginDateTime, включительно. */ + val beginFrom: OffsetDateTime? = null, + + /** Верхняя граница beginDateTime, включительно. */ + val beginTo: OffsetDateTime? = null, + + /** Нижняя граница endDateTime, включительно. */ + val endFrom: OffsetDateTime? = null, + + /** Верхняя граница endDateTime, включительно. */ + val endTo: OffsetDateTime? = null, + + /** Включать soft-deleted заявки в результат. */ + val includeDeleted: Boolean = false, + + /** Номер страницы, начиная с 0. */ + @field:Min(0) + val page: Int = 0, + + /** Размер страницы. */ + @field:Min(1) + @field:Max(500) + val size: Int = 50, + + /** Сортировка в формате field,direction. */ + val sort: String = "createdAt,desc", +) + +/** + * Доменный статус заявки в OpenAPI. + */ +enum class RequestStatusDto { + /** Заявка прошла валидацию и принята сервисом. */ + ACCEPTED, + + /** Заявка актуальна для обработки. */ + ACTIVE, + + /** Заявка выполнена. */ + COMPLETED, + + /** Временное окно заявки истекло. */ + EXPIRED, + + /** Заявка мягко удалена. */ + DELETED, +} + +/** + * Статус ответа delete endpoint. + */ +enum class DeleteRequestStatusDto { + /** Заявка удалена или уже была удалена ранее. */ + DELETED, +} + +/** + * Вычисленный тип заявки по наличию optics/rsa. + */ +enum class SurveyTypeDto { + /** В заявке указан только optics. */ + OPTICS, + + /** В заявке указан только rsa. */ + RSA, + + /** В заявке указаны optics и rsa. */ + COMBINED, +} + +/** + * Результат оптической съемки. + */ +enum class OpticsResultTypeDto { + /** Панхроматическая съемка. */ + PANCHROMATIC, + + /** Мультиспектральная съемка. */ + MULTISPECTRAL, + + /** Паншарпенинг. */ + PANSHARPENING, +} + +/** + * Тип аппаратуры РСА. + */ +enum class RsaResultTypeDto { + /** Spotlight режим. */ + SPOTLIGHT, + + /** Stripmap режим. */ + STRIPMAP, + + /** ScanSAR режим. */ + SCANSAR, +} + +/** + * Тип поляризации РСА. + */ +enum class PolarisationDto { + /** Горизонтальная передача и горизонтальный прием. */ + HH, + + /** Вертикальная передача и вертикальный прием. */ + VV, +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RouteDto.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RouteDto.kt new file mode 100644 index 0000000..2c4f042 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RouteDto.kt @@ -0,0 +1,46 @@ +package org.nstart.dep265.requestservice.dto + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import java.time.LocalDateTime +import java.util.UUID + +/** + * Абсолютный диапазон крена маршрута в градусах. + */ +data class RouteAngleRangeDto( + /** Минимальное абсолютное значение крена. */ + val min: Double, + + /** Максимальное абсолютное значение крена. */ + val max: Double, +) { + init { + require(min <= max) { "Минимальный крен маршрута не может быть больше максимального" } + } + + fun average(): Double = (min + max) / 2.0 +} + +/** + * Внутренняя DTO маршрута, используемая request-service для matching заявок с маршрутами. + */ +data class RouteDto( + /** Идентификатор маршрута. */ + val routeId: UUID, + + /** Начало интервала выполнения маршрута. */ + @field:NotNull(message = "Время начала маршрута обязательно") + val intervalBegin: LocalDateTime, + + /** Конец интервала выполнения маршрута. */ + @field:NotNull(message = "Время окончания маршрута обязательно") + val intervalEnd: LocalDateTime, + + /** Абсолютный диапазон крена маршрута. */ + val rollAngle: RouteAngleRangeDto, + + /** Геометрия маршрута в WKT. */ + @field:NotBlank(message = "Геометрия маршрута обязательна") + val geometry: String, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/EarthCellEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/EarthCellEntity.kt new file mode 100644 index 0000000..1e9b565 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/EarthCellEntity.kt @@ -0,0 +1,69 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import org.hibernate.annotations.Fetch +import org.hibernate.annotations.FetchMode +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO + +@Entity +@Table(name = "earth_cells") +class EarthCellEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cell_id") + var cellId: Long? = null, + + @Column(name = "cell_num", nullable = false) + var cellNum: Long = 0, + + @Column(name = "longitude") + var longitude: Double? = null, + + @Column(name = "latitude") + var latitude: Double? = null, + + @Column(name = "importance", nullable = false) + var importance: Double = 0.0, + + @Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT") + var contour: String = "", + + /** Привязанные к ячейке фрагменты заявок для legacy earth-grid API. */ + @OneToMany(fetch = FetchType.LAZY, mappedBy = "cell", cascade = [CascadeType.PERSIST, CascadeType.MERGE], orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) + var requestProjections: MutableList = mutableListOf(), +) { + fun toDto(): EarthCellDTO { + val dto = EarthCellDTO( + id = cellId ?: 0, + num = cellNum, + latitude = latitude ?: 0.0, + longitude = longitude ?: 0.0, + importance = importance, + contour = contour, + ) + return dto + } + + fun toDtoWithRequests(): EarthCellWithRequestsDTO { + val dto = EarthCellWithRequestsDTO( + id = cellId ?: 0, + num = cellNum, + latitude = latitude ?: 0.0, + longitude = longitude ?: 0.0, + importance = importance, + contour = contour, + requests = requestProjections.map { requestProjection -> requestProjection.toDto() }, + ) + return dto + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/EarthGridSettingsEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/EarthGridSettingsEntity.kt new file mode 100644 index 0000000..8fc1fbf --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/EarthGridSettingsEntity.kt @@ -0,0 +1,22 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "earth_grid_settings") +class EarthGridSettingsEntity( + @Id + @Column(name = "settings_id") + var settingsId: Long = DEFAULT_SETTINGS_ID, + + @Column(name = "calculation_step", nullable = false) + var calculationStep: Double = DEFAULT_CALCULATION_STEP, +) { + companion object { + const val DEFAULT_SETTINGS_ID = 1L + const val DEFAULT_CALCULATION_STEP = 2.0 + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt new file mode 100644 index 0000000..77c8805 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt @@ -0,0 +1,56 @@ +package org.nstart.dep265.requestservice.entity + +import com.fasterxml.jackson.databind.JsonNode +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table( + name = "outbox_events", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_outbox_events_event_type_aggregate_id", + columnNames = ["event_type", "aggregate_id"], + ), + ], +) +class OutboxEventEntity( + @Id + @Column(name = "id", nullable = false) + var id: UUID, + + @Column(name = "event_type", nullable = false, length = 128) + var eventType: String, + + @Column(name = "aggregate_type", nullable = false, length = 64) + var aggregateType: String, + + @Column(name = "aggregate_id", nullable = false) + var aggregateId: UUID, + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload", nullable = false) + var payload: JsonNode, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + var status: OutboxEventStatus, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime, + + @Column(name = "published_at") + var publishedAt: LocalDateTime? = null, + + @Column(name = "error_message", columnDefinition = "TEXT") + var errorMessage: String? = null, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventStatus.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventStatus.kt new file mode 100644 index 0000000..ee12366 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventStatus.kt @@ -0,0 +1,7 @@ +package org.nstart.dep265.requestservice.entity + +enum class OutboxEventStatus { + NEW, + PUBLISHED, + FAILED, +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestCellEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestCellEntity.kt new file mode 100644 index 0000000..60e99ed --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestCellEntity.kt @@ -0,0 +1,50 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT +import java.util.UUID + +@Entity +@Table(name = "request_cells") +class RequestCellEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + var id: Long? = null, + + @Column(name = "request_id", nullable = false) + var requestId: UUID = UUID.randomUUID(), + + @Column(name = "coverage_percent") + var coveragePercent: Double? = null, + + /** Snapshot важности master-заявки, нужный для расчета importance ячейки. */ + @Column(name = "importance", nullable = false) + var importance: Double = 0.0, + + @Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT") + var contour: String = "", + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cell_id", nullable = false) + var cell: EarthCellEntity? = null, +) { + fun toDto(): CellRequestDOT { + val dto = CellRequestDOT( + id = id ?: 0, + requestId = requestId.toString(), + covPercent = coveragePercent ?: 0.0, + importance = importance, + contour = contour, + ) + return dto + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestEntity.kt new file mode 100644 index 0000000..df71af6 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestEntity.kt @@ -0,0 +1,89 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.Column +import jakarta.persistence.CollectionTable +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.Table +import jakarta.persistence.Version +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "requests") +class RequestEntity( + @Id + @Column(name = "id", nullable = false) + var id: UUID, + + @Column(name = "name", nullable = false, length = 255) + var name: String, + + @Column(name = "geometry", nullable = false, columnDefinition = "TEXT") + var geometry: String, + + @Column(name = "remaining_geometry", nullable = false, columnDefinition = "TEXT") + var remainingGeometry: String, + + @Column(name = "geometry_area", nullable = false) + var geometryArea: Double, + + @Column(name = "remaining_area", nullable = false) + var remainingArea: Double, + + @Column(name = "coverage_required_percent", nullable = false) + var coverageRequiredPercent: Double = 100.0, + + @Column(name = "importance", nullable = false) + var importance: Double, + + @Column(name = "begin_date_time", nullable = false) + var beginDateTime: LocalDateTime, + + @Column(name = "end_date_time", nullable = false) + var endDateTime: LocalDateTime, + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "request_kpp", joinColumns = [JoinColumn(name = "request_id")]) + @Column(name = "kpp", nullable = false) + var kpp: MutableList = mutableListOf(), + + @Column(name = "high_priority_transmit", nullable = false) + var highPriorityTransmit: Boolean = false, + + @Enumerated(EnumType.STRING) + @Column(name = "survey_type", nullable = false, length = 32) + var surveyType: RequestSurveyType, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + var status: RequestStatus, + + @Column(name = "match_count", nullable = false) + var matchCount: Int = 0, + + @Column(name = "last_matched_at") + var lastMatchedAt: LocalDateTime? = null, + + @Column(name = "completed_at") + var completedAt: LocalDateTime? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime, + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime, + + @Column(name = "deleted_at") + var deletedAt: LocalDateTime? = null, + + /** Версия optimistic locking для безопасных конкурентных обновлений. */ + @Version + @Column(name = "version", nullable = false) + var version: Long = 0, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestOpticsParamsEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestOpticsParamsEntity.kt new file mode 100644 index 0000000..560ff23 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestOpticsParamsEntity.kt @@ -0,0 +1,47 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.util.UUID + +@Entity +@Table(name = "request_optics_params") +class RequestOpticsParamsEntity( + @Id + @Column(name = "request_id", nullable = false) + var requestId: UUID, + + @Enumerated(EnumType.STRING) + @Column(name = "result_type", nullable = false, length = 32) + var resultType: OpticsResultType, + + @Column(name = "resolution", nullable = false) + var resolution: Double, + + @Column(name = "sun_angle_min", nullable = false) + var sunAngleMin: Double = 10.0, + + @Column(name = "sun_angle_max", nullable = false) + var sunAngleMax: Double = 90.0, + + @Column(name = "clouds", nullable = false) + var clouds: Double = 100.0, +) + +/** + * Результат оптической съемки. + */ +enum class OpticsResultType { + /** Панхроматическая съемка. */ + PANCHROMATIC, + + /** Мультиспектральная съемка. */ + MULTISPECTRAL, + + /** Паншарпенинг. */ + PANSHARPENING, +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestRouteMatchEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestRouteMatchEntity.kt new file mode 100644 index 0000000..9e9896f --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestRouteMatchEntity.kt @@ -0,0 +1,46 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "request_route_matches") +class RequestRouteMatchEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + var id: Long? = null, + + @Column(name = "request_id", nullable = false) + var requestId: UUID, + + @Column(name = "route_id", nullable = false) + var routeId: UUID, + + @Column(name = "original_intersection_geometry", nullable = false, columnDefinition = "TEXT") + var originalIntersectionGeometry: String, + + @Column(name = "original_intersection_area", nullable = false) + var originalIntersectionArea: Double, + + @Column(name = "applied_intersection_geometry", columnDefinition = "TEXT") + var appliedIntersectionGeometry: String? = null, + + @Column(name = "applied_intersection_area", nullable = false) + var appliedIntersectionArea: Double, + + @Column(name = "coverage_delta_percent", nullable = false) + var coverageDeltaPercent: Double, + + @Column(name = "contributes_to_coverage", nullable = false) + var contributesToCoverage: Boolean, + + @Column(name = "matched_at", nullable = false) + var matchedAt: LocalDateTime, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestRsaParamsEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestRsaParamsEntity.kt new file mode 100644 index 0000000..65d93d9 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestRsaParamsEntity.kt @@ -0,0 +1,56 @@ +package org.nstart.dep265.requestservice.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.util.UUID + +@Entity +@Table(name = "request_rsa_params") +class RequestRsaParamsEntity( + @Id + @Column(name = "request_id", nullable = false) + var requestId: UUID, + + @Enumerated(EnumType.STRING) + @Column(name = "result_type", nullable = false, length = 32) + var resultType: RsaResultType, + + @Column(name = "interferometry", nullable = false) + var interferometry: Boolean = false, + + @Enumerated(EnumType.STRING) + @Column(name = "polarisation", nullable = false, length = 8) + var polarisation: RsaPolarisation, + + @Column(name = "resolution", nullable = false) + var resolution: Double, +) + +/** + * Тип аппаратуры РСА. + */ +enum class RsaResultType { + /** Spotlight режим. */ + SPOTLIGHT, + + /** Stripmap режим. */ + STRIPMAP, + + /** ScanSAR режим. */ + SCANSAR, +} + +/** + * Тип поляризации РСА. + */ +enum class RsaPolarisation { + /** Горизонтальная передача и горизонтальный прием. */ + HH, + + /** Вертикальная передача и вертикальный прием. */ + VV, +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestStatus.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestStatus.kt new file mode 100644 index 0000000..50a05a6 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestStatus.kt @@ -0,0 +1,21 @@ +package org.nstart.dep265.requestservice.entity + +/** + * Бизнес-статус заявки. + */ +enum class RequestStatus { + /** Заявка прошла валидацию и сохранена. */ + ACCEPTED, + + /** Заявка актуальна для обработки. */ + ACTIVE, + + /** Заявка выполнилась по проценту покрытия. */ + COMPLETED, + + /** Интервал заявки завершился до достижения требуемого покрытия. */ + EXPIRED, + + /** Заявка мягко удалена. */ + DELETED, +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestSurveyType.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestSurveyType.kt new file mode 100644 index 0000000..26e0d02 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/RequestSurveyType.kt @@ -0,0 +1,15 @@ +package org.nstart.dep265.requestservice.entity + +/** + * Вычисленный тип заявки по наличию optics/rsa. + */ +enum class RequestSurveyType { + /** В заявке указан только optics. */ + OPTICS, + + /** В заявке указан только rsa. */ + RSA, + + /** В заявке указаны optics и rsa. */ + COMBINED, +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt new file mode 100644 index 0000000..436f710 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt @@ -0,0 +1,52 @@ +package org.nstart.dep265.requestservice.kafka + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.validation.ConstraintViolationException +import jakarta.validation.Validator +import org.nstart.dep265.requestservice.mapper.RoutePassportMapper +import org.nstart.dep265.requestservice.service.RouteMatchingService +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage + +@Component +class RequestKafkaListener( + private val objectMapper: ObjectMapper, + + private val validator: Validator, + + private val routeMatchingService: RouteMatchingService, + + private val routePassportMapper: RoutePassportMapper, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @KafkaListener( + topics = ["\${app.kafka.topics.route}"], + groupId = "\${spring.kafka.consumer.group-id}-route", + ) + fun consumeRoute(message: String) { + try { + val routeMessage = objectMapper.readValue( + message, + object : TypeReference>() {}, + ) + val route = routePassportMapper.toRouteDto(routeMessage.data) + validate(route) + routeMatchingService.process(route) + } catch (exception: Exception) { + log.error("Failed to process Kafka route payload", exception) + throw exception + } + } + + private fun validate(value: Any) { + val violations = validator.validate(value) + if (violations.isNotEmpty()) { + throw ConstraintViolationException(violations) + } + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/mapper/RoutePassportMapper.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/mapper/RoutePassportMapper.kt new file mode 100644 index 0000000..cff1072 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/mapper/RoutePassportMapper.kt @@ -0,0 +1,36 @@ +package org.nstart.dep265.requestservice.mapper + +import org.nstart.dep265.requestservice.dto.RouteAngleRangeDto +import org.nstart.dep265.requestservice.dto.RouteDto +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import kotlin.math.abs + +@Component +class RoutePassportMapper { + fun toRouteDto(routePassportDto: RoutePassportDto): RouteDto = + RouteDto( + routeId = routePassportDto.routeId, + intervalBegin = routePassportDto.intervalBegin, + intervalEnd = routePassportDto.intervalEnd, + rollAngle = routePassportDto.rollAngle.toAbsoluteRange(), + geometry = routePassportDto.geometry, + ) + + private fun AngleRangeDto.toAbsoluteRange(): RouteAngleRangeDto { + val minAbs = abs(min.toDouble()) + val maxAbs = abs(max.toDouble()) + val absoluteMin = if (min.signum() <= 0 && max.signum() >= 0) { + 0.0 + } else { + minOf(minAbs, maxAbs) + } + val absoluteMax = maxOf(minAbs, maxAbs) + + return RouteAngleRangeDto( + min = absoluteMin, + max = absoluteMax, + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/EarthCellRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/EarthCellRepository.kt new file mode 100644 index 0000000..c8f1c34 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/EarthCellRepository.kt @@ -0,0 +1,71 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.util.Optional +import java.util.UUID + +interface EarthCellRepository : JpaRepository { + @Query( + value = """ + select earthCell + from EarthCellEntity earthCell + where (:minImportance is null or earthCell.importance > :minImportance) + """, + countQuery = """ + select count(earthCell) + from EarthCellEntity earthCell + where (:minImportance is null or earthCell.importance > :minImportance) + """ + ) + fun findCells( + @Param("minImportance") minImportance: Double?, + pageable: Pageable, + ): Page + + @EntityGraph(attributePaths = ["requestProjections"]) + @Query( + """ + select distinct earthCell + from EarthCellEntity earthCell + where (:minImportance is null or earthCell.importance > :minImportance) + order by earthCell.importance desc, earthCell.cellNum asc + """ + ) + fun findCellsWithRequestProjections( + @Param("minImportance") minImportance: Double?, + ): List + + @Query( + value = "SELECT * FROM earth_cells WHERE ST_Intersects(ST_GeomFromText(:polygon, 4326), contour_geom)", + nativeQuery = true, + ) + fun findIntersectingByContour(@Param("polygon") polygonWkt: String): List + + @Query( + value = "SELECT * FROM earth_cells WHERE cell_id IN (SELECT cell_id FROM request_cells WHERE request_id = :requestId)", + nativeQuery = true, + ) + fun findByRequestId(@Param("requestId") requestId: UUID): List + + fun findByCellNum(cellNum: Long): Optional + + fun findAllByImportanceGreaterThanOrderByImportanceDesc(importance: Double): List + + @EntityGraph(attributePaths = ["requestProjections"]) + @Query("select distinct earthCell from EarthCellEntity earthCell where earthCell.importance > :importance order by earthCell.importance desc") + fun findDetailedByImportanceGreaterThanOrderByImportanceDesc(@Param("importance") importance: Double): List + + @EntityGraph(attributePaths = ["requestProjections"]) + @Query("select earthCell from EarthCellEntity earthCell where earthCell.cellId = :cellId") + fun findWithRequestProjectionsByCellId(@Param("cellId") cellId: Long): Optional + + @EntityGraph(attributePaths = ["requestProjections"]) + @Query("select earthCell from EarthCellEntity earthCell where earthCell.cellNum = :cellNum") + fun findWithRequestProjectionsByCellNum(@Param("cellNum") cellNum: Long): Optional +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/EarthGridSettingsRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/EarthGridSettingsRepository.kt new file mode 100644 index 0000000..1cb666b --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/EarthGridSettingsRepository.kt @@ -0,0 +1,6 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface EarthGridSettingsRepository : JpaRepository diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt new file mode 100644 index 0000000..02459c1 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt @@ -0,0 +1,83 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.OutboxEventEntity +import org.nstart.dep265.requestservice.entity.OutboxEventStatus +import org.springframework.data.domain.Pageable +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 java.time.LocalDateTime +import java.util.UUID + +interface OutboxEventRepository : JpaRepository { + fun existsByEventTypeAndAggregateId(eventType: String, aggregateId: UUID): Boolean + + fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List + + /** + * Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already + * being published by another transaction. + */ + @Query( + value = """ + SELECT * + FROM outbox_events + WHERE event_type = :eventType + AND status IN ('NEW', 'FAILED') + ORDER BY + CASE WHEN status = 'NEW' THEN 0 ELSE 1 END, + created_at ASC + LIMIT :batchSize + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true, + ) + fun findRequestCompletedPublishableForUpdateSkipLocked( + @Param("eventType") eventType: String, + @Param("batchSize") batchSize: Int, + ): List + + /** + * Inserts a new outbox event atomically and treats the event_type + aggregate_id conflict as + * an idempotent duplicate. Production PostgreSQL stores payload as JSONB. + */ + @Modifying + @Query( + value = """ + INSERT INTO outbox_events ( + id, + event_type, + aggregate_type, + aggregate_id, + payload, + status, + created_at, + published_at, + error_message + ) + VALUES ( + :id, + :eventType, + :aggregateType, + :aggregateId, + CAST(:payload AS jsonb), + :status, + :createdAt, + NULL, + NULL + ) + ON CONFLICT (event_type, aggregate_id) DO NOTHING + """, + nativeQuery = true, + ) + fun insertIfAbsent( + @Param("id") id: UUID, + @Param("eventType") eventType: String, + @Param("aggregateType") aggregateType: String, + @Param("aggregateId") aggregateId: UUID, + @Param("payload") payload: String, + @Param("status") status: String, + @Param("createdAt") createdAt: LocalDateTime, + ): Int +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestCellRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestCellRepository.kt new file mode 100644 index 0000000..93c7d7d --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestCellRepository.kt @@ -0,0 +1,30 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.springframework.data.jpa.repository.EntityGraph +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 java.util.UUID + +interface RequestCellRepository : JpaRepository { + @Modifying + @Transactional + @Query("delete from RequestCellEntity requestCell where requestCell.requestId = :requestId") + fun deleteByRequestId(@Param("requestId") requestId: UUID) + + fun findByRequestId(requestId: UUID): List + + @EntityGraph(attributePaths = ["cell"]) + @Query( + """ + select requestCell + from RequestCellEntity requestCell + where requestCell.requestId = :requestId + order by requestCell.id asc + """ + ) + fun findWithCellByRequestId(@Param("requestId") requestId: UUID): List +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestOpticsParamsRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestOpticsParamsRepository.kt new file mode 100644 index 0000000..5dafafb --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestOpticsParamsRepository.kt @@ -0,0 +1,7 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface RequestOpticsParamsRepository : JpaRepository diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt new file mode 100644 index 0000000..8b05de9 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt @@ -0,0 +1,109 @@ +package org.nstart.dep265.requestservice.repository + +import jakarta.persistence.LockModeType +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +interface RequestRepository : JpaRepository { + @Query( + value = """ + select distinct request + from RequestEntity request + left join request.kpp requestKpp + where (:includeDeleted = true or request.status <> :deletedStatus) + and (:status is null or request.status = :status) + and (:surveyType is null or request.surveyType = :surveyType) + and (:kpp is null or requestKpp = :kpp) + and (:highPriorityTransmit is null or request.highPriorityTransmit = :highPriorityTransmit) + and (cast(:beginFrom as LocalDateTime) is null or request.beginDateTime >= :beginFrom) + and (cast(:beginTo as LocalDateTime) is null or request.beginDateTime <= :beginTo) + and (cast(:endFrom as LocalDateTime) is null or request.endDateTime >= :endFrom) + and (cast(:endTo as LocalDateTime) is null or request.endDateTime <= :endTo) + """, + countQuery = """ + select count(distinct request) + from RequestEntity request + left join request.kpp requestKpp + where (:includeDeleted = true or request.status <> :deletedStatus) + and (:status is null or request.status = :status) + and (:surveyType is null or request.surveyType = :surveyType) + and (:kpp is null or requestKpp = :kpp) + and (:highPriorityTransmit is null or request.highPriorityTransmit = :highPriorityTransmit) + and (cast(:beginFrom as LocalDateTime) is null or request.beginDateTime >= :beginFrom) + and (cast(:beginTo as LocalDateTime) is null or request.beginDateTime <= :beginTo) + and (cast(:endFrom as LocalDateTime) is null or request.endDateTime >= :endFrom) + and (cast(:endTo as LocalDateTime) is null or request.endDateTime <= :endTo) + """ + ) + fun findRequests( + @Param("includeDeleted") includeDeleted: Boolean, + @Param("deletedStatus") deletedStatus: RequestStatus, + @Param("status") status: RequestStatus?, + @Param("surveyType") surveyType: RequestSurveyType?, + @Param("kpp") kpp: Int?, + @Param("highPriorityTransmit") highPriorityTransmit: Boolean?, + @Param("beginFrom") beginFrom: LocalDateTime?, + @Param("beginTo") beginTo: LocalDateTime?, + @Param("endFrom") endFrom: LocalDateTime?, + @Param("endTo") endTo: LocalDateTime?, + pageable: Pageable, + ): Page + + /** + * Возвращает идентификаторы активных заявок, окно которых пересекается с окном маршрута. + */ + @Query( + """ + select request.id + from RequestEntity request + where request.status in :statuses + and request.beginDateTime <= :routeEnd + and request.endDateTime >= :routeBegin + and request.deletedAt is null + """ + ) + fun findRequestIdsForRoute( + @Param("statuses") statuses: Collection, + @Param("routeBegin") routeBegin: LocalDateTime, + @Param("routeEnd") routeEnd: LocalDateTime, + ): List + + /** + * Сериализует применение маршрута к одной заявке через row-level lock. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + select request + from RequestEntity request + where request.id = :id + """ + ) + fun findByIdForUpdate(@Param("id") id: UUID): Optional + + @Query( + """ + select request + from RequestEntity request + where request.beginDateTime < :selectionEnd + and :selectionStart < request.endDateTime + and request.deletedAt is null + """ + ) + fun findRequestsIntersectingSelectionInterval( + @Param("selectionStart") selectionStart: LocalDateTime, + @Param("selectionEnd") selectionEnd: LocalDateTime, + ): List + + fun findByStatusIn(statuses: Collection): List +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRouteMatchRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRouteMatchRepository.kt new file mode 100644 index 0000000..71f500b --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRouteMatchRepository.kt @@ -0,0 +1,11 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface RequestRouteMatchRepository : JpaRepository { + fun existsByRequestIdAndRouteId(requestId: UUID, routeId: UUID): Boolean + + fun deleteByRequestId(requestId: UUID) +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRsaParamsRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRsaParamsRepository.kt new file mode 100644 index 0000000..b0faa4d --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRsaParamsRepository.kt @@ -0,0 +1,7 @@ +package org.nstart.dep265.requestservice.repository + +import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface RequestRsaParamsRepository : JpaRepository diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/CellsQueryService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/CellsQueryService.kt new file mode 100644 index 0000000..975ec28 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/CellsQueryService.kt @@ -0,0 +1,206 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.dto.CellRequestFragmentResponseDto +import org.nstart.dep265.requestservice.dto.CellSummaryResponseDto +import org.nstart.dep265.requestservice.dto.CellWithRequestsResponseDto +import org.nstart.dep265.requestservice.dto.CellsListResponseDto +import org.nstart.dep265.requestservice.dto.CellsWithRequestsResponseDto +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO +import java.util.UUID + +class InvalidCellsQueryException(message: String) : IllegalArgumentException(message) + +@Service +class CellsQueryService( + private val earthCellRepository: EarthCellRepository, + private val earthGridCatalogService: EarthGridCatalogService, +) { + @Transactional(readOnly = true) + fun listCells( + minImportance: Double?, + countLat: Int?, + countLong: Int?, + page: Int, + size: Int, + sort: String?, + ): CellsListResponseDto { + validateQuery(minImportance, countLat, countLong, page, size) + + val cells = earthCellRepository.findCells( + minImportance = minImportance, + pageable = PageRequest.of(page, size, sort.toCellsSort()), + ) + + return CellsListResponseDto( + items = cells.content.map { earthCell -> earthCell.toSummaryResponse() }, + page = page, + size = size, + totalItems = cells.totalElements, + totalPages = cells.totalPages, + ) + } + + @Transactional(readOnly = true) + fun listCellsWithRequests( + minImportance: Double?, + countLat: Int?, + countLong: Int?, + ): CellsWithRequestsResponseDto { + validateWithRequestsQuery(minImportance, countLat, countLong) + + val items = if (countLat == null && countLong == null) { + earthCellRepository.findCellsWithRequestProjections(minImportance) + .map { earthCell -> earthCell.toWithRequestsResponse() } + } else { + earthGridCatalogService.allWithRequests( + minImportance = minImportance, + countLat = countLat, + countLong = countLong, + ) + .map { earthCell -> earthCell.toWithRequestsResponse() } + } + + return CellsWithRequestsResponseDto(items = items) + } + + private fun validateQuery( + minImportance: Double?, + countLat: Int?, + countLong: Int?, + page: Int, + size: Int, + ) { + if (minImportance != null && minImportance < 0.0) { + throw InvalidCellsQueryException("minImportance must be greater than or equal to 0") + } + if (countLat != null && countLat <= 0) { + throw InvalidCellsQueryException("countLat must be greater than 0") + } + if (countLong != null && countLong <= 0) { + throw InvalidCellsQueryException("countLong must be greater than 0") + } + if (countLat != null || countLong != null) { + throw InvalidCellsQueryException("Cell aggregation by countLat/countLong is not implemented yet") + } + if (page < 0) { + throw InvalidCellsQueryException("page must be greater than or equal to 0") + } + if (size < 1 || size > 500) { + throw InvalidCellsQueryException("size must be between 1 and 500") + } + } + + private fun validateWithRequestsQuery( + minImportance: Double?, + countLat: Int?, + countLong: Int?, + ) { + if (minImportance != null && minImportance < 0.0) { + throw InvalidCellsQueryException("minImportance must be greater than or equal to 0") + } + if (countLat != null && countLat <= 0) { + throw InvalidCellsQueryException("countLat must be greater than 0") + } + if (countLong != null && countLong <= 0) { + throw InvalidCellsQueryException("countLong must be greater than 0") + } + if ((countLat == null) != (countLong == null)) { + throw InvalidCellsQueryException("countLat and countLong must be provided together") + } + } + + private fun String?.toCellsSort(): Sort { + if (isNullOrBlank()) { + return DEFAULT_SORT + } + + val parts = split(",").map { part -> part.trim() }.filter { part -> part.isNotEmpty() } + val field = parts.firstOrNull() ?: DEFAULT_SORT_FIELD + val direction = parts.getOrNull(1)?.let { value -> + when (value.lowercase()) { + "asc" -> Sort.Direction.ASC + "desc" -> Sort.Direction.DESC + else -> throw InvalidCellsQueryException("Unsupported sort direction: $value") + } + } ?: Sort.Direction.ASC + val property = ALLOWED_SORT_FIELDS[field] + ?: throw InvalidCellsQueryException("Unsupported sort field: $field") + val requestedSort = Sort.by(direction, property) + + return if (property == "cellNum") { + requestedSort + } else { + requestedSort.and(Sort.by(Sort.Direction.ASC, "cellNum")) + } + } + + private fun EarthCellEntity.toSummaryResponse(): CellSummaryResponseDto { + return CellSummaryResponseDto( + cellNum = cellNum, + latitude = latitude ?: 0.0, + longitude = longitude ?: 0.0, + importance = importance, + contour = contour, + ) + } + + private fun EarthCellEntity.toWithRequestsResponse(): CellWithRequestsResponseDto { + return CellWithRequestsResponseDto( + cellNum = cellNum, + latitude = latitude ?: 0.0, + longitude = longitude ?: 0.0, + importance = importance, + contour = contour, + requests = requestProjections.map { requestProjection -> requestProjection.toFragmentResponse() }, + ) + } + + private fun RequestCellEntity.toFragmentResponse(): CellRequestFragmentResponseDto { + return CellRequestFragmentResponseDto( + requestId = requestId, + contour = contour, + coveragePercent = coveragePercent ?: 0.0, + importance = importance, + ) + } + + private fun EarthCellWithRequestsDTO.toWithRequestsResponse(): CellWithRequestsResponseDto { + return CellWithRequestsResponseDto( + cellNum = num, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = contour, + requests = requests.map { request -> request.toFragmentResponse() }, + ) + } + + private fun CellRequestDOT.toFragmentResponse(): CellRequestFragmentResponseDto { + return CellRequestFragmentResponseDto( + requestId = UUID.fromString(requestId), + contour = contour, + coveragePercent = covPercent, + importance = importance, + ) + } + + private companion object { + const val DEFAULT_SORT_FIELD = "importance" + val DEFAULT_SORT: Sort = Sort.by(Sort.Direction.DESC, "importance") + .and(Sort.by(Sort.Direction.ASC, "cellNum")) + val ALLOWED_SORT_FIELDS: Map = mapOf( + "cellNum" to "cellNum", + "importance" to "importance", + "latitude" to "latitude", + "longitude" to "longitude", + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/CompatGeometryService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/CompatGeometryService.kt new file mode 100644 index 0000000..31bb094 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/CompatGeometryService.kt @@ -0,0 +1,40 @@ +package org.nstart.dep265.requestservice.service + +import org.geotools.geometry.jts.JTS +import org.geotools.referencing.CRS +import org.springframework.stereotype.Service + +@Service +class CompatGeometryService( + private val geometryService: GeometryService, +) { + fun calculateAreaSqKm(wkt: String): Double { + /** Исходная геометрия берется в WGS84, как и в legacy earth-grid сервисе. */ + val geometry = geometryService.parseGeometry(wkt) + + val sourceCrs = CRS.decode("EPSG:4326") + + /** Проекция World Mollweide используется для стабильного расчета площади. */ + val targetCrs = CRS.decode("EPSG:54009") + + val transform = CRS.findMathTransform(sourceCrs, targetCrs) + val projectedGeometry = JTS.transform(geometry, transform) + val areaSqKm = projectedGeometry.area / 1_000_000.0 + return areaSqKm + } + + fun toGeometryCollection(wkts: Iterable): String { + /** Ненулевые геометрии фильтруются, чтобы не строить битый WKT. */ + val nonBlankWkts = wkts.filter { wkt -> wkt.isNotBlank() }.toList() + if (nonBlankWkts.isEmpty()) { + return "" + } + + val geometryCollection = buildString { + append("GEOMETRYCOLLECTION(") + append(nonBlankWkts.joinToString(",")) + append(")") + } + return geometryCollection + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/EarthGridCatalogService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/EarthGridCatalogService.kt new file mode 100644 index 0000000..3cb5e36 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/EarthGridCatalogService.kt @@ -0,0 +1,271 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO +import kotlin.math.PI +import kotlin.math.floor +import kotlin.math.truncate + +@Service +class EarthGridCatalogService( + private val earthCellRepository: EarthCellRepository, + + private val earthGridSettingsRepository: EarthGridSettingsRepository, + + private val compatGeometryService: CompatGeometryService, + + @Value("\${app.grid.enabled:true}") + private val gridEnabled: Boolean, +) { + private data class AggregatedGridKey( + val latitudeGroup: Long, + val longitudeGroup: Long, + ) + + @Transactional(readOnly = true) + fun all(countLat: Int? = null, countLong: Int? = null): List { + val earthCells = earthCellRepository.findAll() + return aggregateIfRequested(earthCells, countLat, countLong) + } + + @Transactional(readOnly = true) + fun paintByImportance(importance: Double): String { + val earthCells = earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(importance) + /** Контуры ячеек складываются в GEOMETRYCOLLECTION для legacy paint endpoint. */ + val geometryCollection = compatGeometryService.toGeometryCollection(earthCells.map { earthCell -> earthCell.contour }) + return geometryCollection + } + + @Transactional(readOnly = true) + fun byImportance( + importance: Double, + countLat: Int? = null, + countLong: Int? = null, + ): List { + val earthCells = earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(importance) + return aggregateIfRequested(earthCells, countLat, countLong) + } + + @Transactional(readOnly = true) + fun allWithRequests( + minImportance: Double? = null, + countLat: Int? = null, + countLong: Int? = null, + ): List { + val earthCells = earthCellRepository.findCellsWithRequestProjections(minImportance) + return aggregateWithRequestsIfRequested(earthCells, countLat, countLong) + } + + @Transactional(readOnly = true) + fun byId(cellId: Long) = earthCellRepository.findWithRequestProjectionsByCellId(cellId) + .map { earthCell -> earthCell.toDtoWithRequests() } + + @Transactional(readOnly = true) + fun byNum(cellNum: Long) = earthCellRepository.findWithRequestProjectionsByCellNum(cellNum) + .map { earthCell -> earthCell.toDtoWithRequests() } + + @Transactional(readOnly = true) + fun byNumForPaint(cellNum: Long): String { + val earthCell = earthCellRepository.findWithRequestProjectionsByCellNum(cellNum).orElse(null) ?: return "" + + /** Контур самой ячейки должен идти первым, как это делал старый earth-grid. */ + val wkts = mutableListOf() + wkts.add(earthCell.contour) + wkts.addAll(earthCell.requestProjections.map { requestProjection -> requestProjection.contour }) + + val geometryCollection = compatGeometryService.toGeometryCollection(wkts) + return geometryCollection + } + + @Transactional + fun init(step: Double) { + require(step > 0.0) { "Шаг сетки должен быть больше 0" } + updateCalculationStep(step) + + if (!gridEnabled) { + return + } + + /** Все старые ячейки удаляются, потому что init — это полный rebuild каталога. */ + earthCellRepository.deleteAllInBatch() + + /** Текущая широта стартует по правилам old earth-grid сервиса. */ + var latitude = -90 + step + while (latitude <= 90) { + val line = mutableListOf() + + var longitude = 0.0 + while (longitude < (360-0.001)) { + /** Номер ячейки вычисляется legacy-формулой через индексы b/l. */ + val cellNum = getInd(latitude * PI / 180.0, longitude * PI / 180.0) + + /** Контур повторяет старую геометрию earth-grid 1x1 градус. */ + val contour = "POLYGON (($longitude $latitude, ${longitude - step} $latitude, ${longitude - step} ${latitude - step}, $longitude ${latitude - step}, $longitude $latitude))" + + val earthCell = EarthCellEntity( + cellId = null, + cellNum = cellNum, + longitude = longitude, + latitude = latitude, + importance = 0.0, + contour = contour, + ) + line.add(earthCell) + longitude += step + } + + earthCellRepository.saveAll(line) + latitude += step + } + } + + private fun updateCalculationStep(step: Double) { + val settings = earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + .orElseGet { + EarthGridSettingsEntity( + settingsId = EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, + calculationStep = EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, + ) + } + settings.calculationStep = step + earthGridSettingsRepository.save(settings) + } + + private fun aggregateIfRequested( + earthCells: List, + countLat: Int?, + countLong: Int?, + ): List { + if (countLat == null && countLong == null) { + return earthCells.map { earthCell -> earthCell.toDto() } + } + + val latitudeCount = countLat ?: 1 + val longitudeCount = countLong ?: 1 + require(latitudeCount > 0) { "Параметр countLat должен быть больше 0" } + require(longitudeCount > 0) { "Параметр countLong должен быть больше 0" } + + val calculationStep = currentCalculationStep() + return earthCells + .groupBy { earthCell -> aggregationKey(earthCell, calculationStep, latitudeCount, longitudeCount) } + .values + .map { group -> aggregateCells(group, calculationStep) } + .sortedWith(compareByDescending { it.importance }.thenBy { it.num }) + } + + private fun aggregateWithRequestsIfRequested( + earthCells: List, + countLat: Int?, + countLong: Int?, + ): List { + if (countLat == null && countLong == null) { + return earthCells.map { earthCell -> earthCell.toDtoWithRequests() } + } + + val latitudeCount = countLat ?: 1 + val longitudeCount = countLong ?: 1 + require(latitudeCount > 0) { "Параметр countLat должен быть больше 0" } + require(longitudeCount > 0) { "Параметр countLong должен быть больше 0" } + + val calculationStep = currentCalculationStep() + return earthCells + .groupBy { earthCell -> aggregationKey(earthCell, calculationStep, latitudeCount, longitudeCount) } + .values + .map { group -> aggregateCellsWithRequests(group, calculationStep) } + .sortedWith(compareByDescending { it.importance }.thenBy { it.num }) + } + + private fun currentCalculationStep(): Double = + earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + .map { settings -> settings.calculationStep } + .orElse(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP) + + private fun aggregationKey( + earthCell: EarthCellEntity, + calculationStep: Double, + countLat: Int, + countLong: Int, + ): AggregatedGridKey { + val latitude = earthCell.latitude ?: 0.0 + val longitude = earthCell.longitude ?: 0.0 + val latitudeIndex = floor((latitude - (-90.0 + calculationStep)) / calculationStep).toLong().coerceAtLeast(0L) + val longitudeIndex = floor(longitude / calculationStep).toLong().coerceAtLeast(0L) + return AggregatedGridKey( + latitudeGroup = latitudeIndex / countLat, + longitudeGroup = longitudeIndex / countLong, + ) + } + + private fun aggregateCells( + earthCells: List, + calculationStep: Double, + ): EarthCellDTO { + val minLongitude = earthCells.minOf { earthCell -> (earthCell.longitude ?: 0.0) - calculationStep } + val maxLongitude = earthCells.maxOf { earthCell -> earthCell.longitude ?: 0.0 } + val minLatitude = earthCells.minOf { earthCell -> (earthCell.latitude ?: 0.0) - calculationStep } + val maxLatitude = earthCells.maxOf { earthCell -> earthCell.latitude ?: 0.0 } + val importance = earthCells.sumOf { earthCell -> earthCell.importance } + val firstCell = earthCells.minWith(compareBy { it.latitude ?: 0.0 }.thenBy { it.longitude ?: 0.0 }) + val contour = "POLYGON (($minLongitude $minLatitude, $maxLongitude $minLatitude, $maxLongitude $maxLatitude, $minLongitude $maxLatitude, $minLongitude $minLatitude))" + + return EarthCellDTO( + id = firstCell.cellId ?: firstCell.cellNum, + num = firstCell.cellNum, + latitude = (minLatitude + maxLatitude) / 2.0, + longitude = (minLongitude + maxLongitude) / 2.0, + importance = importance, + contour = contour, + ) + } + + private fun aggregateCellsWithRequests( + earthCells: List, + calculationStep: Double, + ): EarthCellWithRequestsDTO { + val aggregate = aggregateCells(earthCells, calculationStep) + return EarthCellWithRequestsDTO( + id = aggregate.id, + num = aggregate.num, + latitude = aggregate.latitude, + longitude = aggregate.longitude, + importance = aggregate.importance, + contour = aggregate.contour, + requests = earthCells.flatMap { earthCell -> earthCell.requestProjections.map { it.toDto() } }, + ) + } + + fun getBInd(latitudeRadians: Double): Long { + var latitudeDegrees = latitudeRadians * 180.0 / PI + latitudeDegrees = 90.0 - latitudeDegrees + var index = truncate(latitudeDegrees).toLong() + if (index > 180) { + index = 180 + } + return index + } + + fun getLInd(longitudeRadians: Double): Long { + var longitudeDegrees = longitudeRadians * 180.0 / PI + if (longitudeDegrees < 0) { + longitudeDegrees += 360.0 + } + var index = truncate(longitudeDegrees).toLong() + if (index > 359) { + index = 359 + } + return index + } + + fun getInd(latitudeRadians: Double, longitudeRadians: Double): Long { + /** Итоговый номер повторяет алгоритм old earth-grid сервиса. */ + val cellNum = getBInd(latitudeRadians) * 360 + getLInd(longitudeRadians) + return cellNum + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt new file mode 100644 index 0000000..51af6dc --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt @@ -0,0 +1,118 @@ +package org.nstart.dep265.requestservice.service + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryCollection +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.io.WKTWriter +import org.springframework.stereotype.Service +import java.io.StringReader +import kotlin.math.max +import kotlin.math.min + +@Service +class GeometryService { + private val wktReader = WKTReader() + + private val wktWriter = WKTWriter() + + fun parseGeometry(wkt: String): Geometry { + val geometry = wktReader.read(StringReader(wkt)) + return geometry + } + + fun parsePolygonalGeometry(wkt: String): Geometry { + val geometry = parseGeometry(wkt) + return keepPolygonalGeometry(geometry) + } + + fun toWkt(geometry: Geometry): String { + val wkt = wktWriter.write(geometry) + return wkt + } + + fun area(geometry: Geometry): Double { + val area = geometry.area + return area + } + + fun cutCoverage(remainingGeometryWkt: String, routeGeometryWkt: String): GeometryCutResult { + val remainingGeometry = parsePolygonalGeometry(remainingGeometryWkt) + val routeGeometry = parsePolygonalGeometry(routeGeometryWkt) + val intersection = keepPolygonalGeometry(remainingGeometry.intersection(routeGeometry)) + val coveredArea = intersection.area + + if (coveredArea <= 0.0) { + val result = GeometryCutResult( + remainingGeometryWkt = toWkt(remainingGeometry), + remainingArea = remainingGeometry.area, + coveredArea = 0.0, + hasCoverage = false, + ) + return result + } + + val updatedRemainingGeometry = keepPolygonalGeometry(remainingGeometry.difference(routeGeometry)) + val result = GeometryCutResult( + remainingGeometryWkt = toWkt(updatedRemainingGeometry), + remainingArea = max(updatedRemainingGeometry.area, 0.0), + coveredArea = coveredArea, + hasCoverage = true, + ) + return result + } + + fun coveredPercent(originalArea: Double, remainingArea: Double): Double { + if (originalArea <= 0.0) { + return 0.0 + } + + val coveredArea = max(originalArea - remainingArea, 0.0) + val coveredPercent = (coveredArea / originalArea) * 100.0 + return min(max(coveredPercent, 0.0), 100.0) + } + + fun keepPolygonalGeometry(geometry: Geometry): Geometry { + val polygonalGeometry = when (geometry) { + is Polygon -> geometry + is MultiPolygon -> geometry + is GeometryCollection -> { + val polygons = mutableListOf() + collectPolygons(geometry, polygons) + when { + polygons.isEmpty() -> geometry.factory.createPolygon() + polygons.size == 1 -> polygons.first() + else -> geometry.factory.createMultiPolygon(polygons.toTypedArray()) + } + } + else -> geometry.factory.createPolygon() + } + return polygonalGeometry + } + + private fun collectPolygons(geometry: Geometry, polygons: MutableList) { + when (geometry) { + is Polygon -> if (!geometry.isEmpty) { + polygons.add(geometry) + } + is MultiPolygon, + is GeometryCollection -> { + for (index in 0 until geometry.numGeometries) { + val nestedGeometry = geometry.getGeometryN(index) + collectPolygons(nestedGeometry, polygons) + } + } + } + } +} + +data class GeometryCutResult( + val remainingGeometryWkt: String, + + val remainingArea: Double, + + val coveredArea: Double, + + val hasCoverage: Boolean, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt new file mode 100644 index 0000000..d13e9f0 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt @@ -0,0 +1,21 @@ +package org.nstart.dep265.requestservice.service + +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service + +@Service +class GridRebuildLockService( + private val jdbcTemplate: JdbcTemplate, +) { + fun tryAcquireTransactionLock(): Boolean { + return jdbcTemplate.queryForObject( + "select pg_try_advisory_xact_lock(?)", + Boolean::class.java, + GRID_REBUILD_LOCK_KEY, + ) == true + } + + private companion object { + const val GRID_REBUILD_LOCK_KEY = 26_501_001L + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt new file mode 100644 index 0000000..7cf002b --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt @@ -0,0 +1,51 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import java.time.ZoneOffset + +class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running") + +@Service +class GridRebuildService( + private val gridSettingsService: GridSettingsService, + private val earthGridCatalogService: EarthGridCatalogService, + private val requestCellRepository: RequestCellRepository, + private val requestRepository: RequestRepository, + private val requestGridProjectionService: RequestGridProjectionService, + private val earthCellRepository: EarthCellRepository, + private val gridRebuildLockService: GridRebuildLockService, +) { + @Transactional + fun rebuildGrid(): GridRebuildResponseDto { + if (!gridRebuildLockService.tryAcquireTransactionLock()) { + throw GridRebuildAlreadyRunningException() + } + + val settings = gridSettingsService.getSettings() + val activeRequests = requestRepository.findByStatusIn(ACTIVE_REQUEST_STATUSES) + + requestCellRepository.deleteAllInBatch() + earthGridCatalogService.init(settings.calculationStep) + + activeRequests.forEach { request -> + requestGridProjectionService.rebuildProjection(request) + } + + return GridRebuildResponseDto( + cellsCount = earthCellRepository.count(), + rebuiltRequestProjectionsCount = activeRequests.size.toLong(), + rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC), + ) + } + + private companion object { + val ACTIVE_REQUEST_STATUSES = setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridSettingsService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridSettingsService.kt new file mode 100644 index 0000000..d8c6c26 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridSettingsService.kt @@ -0,0 +1,57 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.dto.GridSettingsResponseDto +import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +class InvalidGridSettingsException(message: String) : IllegalArgumentException(message) + +@Service +class GridSettingsService( + private val earthGridSettingsRepository: EarthGridSettingsRepository, +) { + @Transactional + fun getSettings(): GridSettingsResponseDto { + return getOrCreateSettings().toResponse() + } + + @Transactional + fun updateSettings(request: UpdateGridSettingsRequestDto): GridSettingsResponseDto { + val calculationStep = request.calculationStep + ?: throw InvalidGridSettingsException("calculationStep must be provided") + validateCalculationStep(calculationStep) + + val settings = getOrCreateSettings() + settings.calculationStep = calculationStep + + return earthGridSettingsRepository.save(settings).toResponse() + } + + private fun getOrCreateSettings(): EarthGridSettingsEntity { + return earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + .orElseGet { + earthGridSettingsRepository.save( + EarthGridSettingsEntity( + settingsId = EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, + calculationStep = EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, + ) + ) + } + } + + private fun validateCalculationStep(calculationStep: Double) { + if (calculationStep <= 0.0) { + throw InvalidGridSettingsException("calculationStep must be greater than 0") + } + } + + private fun EarthGridSettingsEntity.toResponse(): GridSettingsResponseDto { + return GridSettingsResponseDto( + settingsId = settingsId, + calculationStep = calculationStep, + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/LongitudeWrapGeometry.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/LongitudeWrapGeometry.kt new file mode 100644 index 0000000..e47f536 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/LongitudeWrapGeometry.kt @@ -0,0 +1,175 @@ +package org.nstart.dep265.requestservice.service + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.CoordinateSequenceFilter +import org.locationtech.jts.geom.Envelope +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryCollection +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.LinearRing +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import kotlin.math.abs +import kotlin.math.floor + +/** + * Утилита нормализации долгот для геометрий, переходящих через нулевой меридиан. + */ +object LongitudeWrapGeometry { + + private val geometryFactory = GeometryFactory() + + /** + * Переводит геометрию в непрерывный диапазон долгот вокруг [0..360]. + */ + fun normalizeToContinuous360(geometry: Geometry): Geometry { + val normalized = shiftNegativeLongitudesTo360(geometry.copy()) + return when (normalized) { + is Polygon -> normalizePolygon(normalized) + is MultiPolygon -> normalizeMultiPolygon(normalized) + else -> normalized + }.let { candidate -> if (candidate.isValid) candidate else candidate.buffer(0.0) } + } + + /** + * Строит поисковые варианты geometry в соседних полосах долгот. + */ + fun buildSearchVariants(geometry: Geometry): List { + val normalized = normalizeToContinuous360(geometry) + return listOf( + shiftLongitude(normalized, -360.0), + normalized, + shiftLongitude(normalized, 360.0), + ).distinctBy { variant -> variant.norm().toText() } + } + + /** + * Выравнивает геометрию к полосе долгот опорной геометрии. + */ + fun alignToReference(geometry: Geometry, reference: Geometry): Geometry { + val normalizedReference = normalizeToContinuous360(reference) + val referenceCenter = envelopeCenterX(normalizedReference.envelopeInternal) + return buildSearchVariants(geometry) + .minByOrNull { candidate -> abs(envelopeCenterX(candidate.envelopeInternal) - referenceCenter) } + ?: normalizeToContinuous360(geometry) + } + + private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry { + geometry.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + val longitude = seq.getX(i) + if (longitude < 0.0) { + seq.setOrdinate(i, 0, longitude + 360.0) + } + } + + override fun isDone(): Boolean = false + + override fun isGeometryChanged(): Boolean = true + }) + geometry.geometryChanged() + return geometry + } + + private fun normalizePolygon(polygon: Polygon): Geometry { + val shell = unwrapRing(polygon.exteriorRing) + val holes = Array(polygon.numInteriorRing) { index -> + unwrapRing(polygon.getInteriorRingN(index)) + } + return shiftIntoCanonicalBand(repairGeometry(polygon.factory.createPolygon(shell, holes))) + } + + private fun normalizeMultiPolygon(multiPolygon: MultiPolygon): Geometry { + val parts = mutableListOf() + for (index in 0 until multiPolygon.numGeometries) { + collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon), parts) + } + + return when (parts.size) { + 0 -> geometryFactory.createPolygon() + 1 -> parts.first() + else -> geometryFactory.createMultiPolygon(parts.toTypedArray()) + } + } + + private fun collectPolygons(geometry: Geometry, parts: MutableList) { + when (geometry) { + is Polygon -> parts += geometry + is MultiPolygon -> { + for (index in 0 until geometry.numGeometries) { + parts += geometry.getGeometryN(index) as Polygon + } + } + is GeometryCollection -> { + for (index in 0 until geometry.numGeometries) { + collectPolygons(geometry.getGeometryN(index), parts) + } + } + } + } + + private fun repairGeometry(geometry: Geometry): Geometry = + if (geometry.isValid) geometry else geometry.buffer(0.0) + + private fun shiftIntoCanonicalBand(geometry: Geometry): Geometry { + val minX = geometry.coordinates.minOfOrNull { coordinate -> coordinate.x } ?: return geometry + if (minX >= 0.0) { + return geometry + } + + val shift = 360.0 * (floor(-minX / 360.0) + 1.0) + return shiftLongitude(geometry, shift) + } + + private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry { + if (delta == 0.0) { + return geometry + } + + val shifted = geometry.copy() + shifted.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + seq.setOrdinate(i, 0, seq.getX(i) + delta) + } + + override fun isDone(): Boolean = false + + override fun isGeometryChanged(): Boolean = true + }) + shifted.geometryChanged() + return shifted + } + + private fun envelopeCenterX(envelope: Envelope): Double = (envelope.minX + envelope.maxX) / 2.0 + + private fun unwrapRing(ring: org.locationtech.jts.geom.LineString): LinearRing { + val source = ring.coordinates + if (source.isEmpty()) { + return ring.factory.createLinearRing() + } + + val first = source.first() + val unwrapped = ArrayList(source.size) + var previousX = first.x + unwrapped.add(Coordinate(previousX, first.y, first.z)) + + for (index in 1 until source.size - 1) { + val current = source[index] + var adjustedX = current.x + + while (adjustedX - previousX > 180.0) { + adjustedX -= 360.0 + } + while (adjustedX - previousX < -180.0) { + adjustedX += 360.0 + } + + unwrapped.add(Coordinate(adjustedX, current.y, current.z)) + previousX = adjustedX + } + + val start = unwrapped.first() + unwrapped.add(Coordinate(start.x, start.y, start.z)) + return ring.factory.createLinearRing(unwrapped.toTypedArray()) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt new file mode 100644 index 0000000..31627eb --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt @@ -0,0 +1,114 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.config.OutboxProperties +import org.nstart.dep265.requestservice.entity.OutboxEventEntity +import org.nstart.dep265.requestservice.entity.OutboxEventStatus +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime +import java.util.concurrent.ExecutionException + +@Service +class OutboxPublisherService( + private val outboxEventRepository: OutboxEventRepository, + + private val kafkaTemplate: KafkaTemplate, + + private val outboxProperties: OutboxProperties, + + @param:Qualifier("outboxTransactionOperations") + private val transactionOperations: TransactionOperations, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @Scheduled(fixedDelayString = "\${pcp.outbox.publish-fixed-delay-ms:5000}") + fun publishScheduledBatch() { + publishPending() + } + + fun publishPending(): Int { + val batchSize = outboxProperties.publishBatchSize.coerceAtLeast(0) + if (batchSize == 0) { + return 0 + } + + return try { + transactionOperations.execute( + TransactionCallback { + val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( + eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, + batchSize = batchSize, + ).distinctBy { it.id } + + events.forEach { event -> + publishAndUpdateStatus(event) + } + events.size + }, + ) ?: 0 + } catch (exception: Exception) { + log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception) + 0 + } + } + + private fun publishAndUpdateStatus(event: OutboxEventEntity) { + try { + kafkaTemplate + .send( + outboxProperties.requestCompletedTopic, + event.aggregateId.toString(), + event.payload.toString(), + ) + .get() + + event.status = OutboxEventStatus.PUBLISHED + event.publishedAt = LocalDateTime.now() + event.errorMessage = null + outboxEventRepository.save(event) + + log.info( + "Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", + event.id, + event.aggregateId, + outboxProperties.requestCompletedTopic, + ) + } catch (exception: Exception) { + val publishException = exception.unwrapExecutionException() + event.status = OutboxEventStatus.FAILED + event.errorMessage = publishException.shortMessage() + outboxEventRepository.save(event) + + log.warn( + "Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", + event.id, + event.aggregateId, + outboxProperties.requestCompletedTopic, + publishException, + ) + } + } + + private fun Exception.unwrapExecutionException(): Throwable { + return if (this is ExecutionException && cause != null) { + cause!! + } else { + this + } + } + + private fun Throwable.shortMessage(): String { + val message = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName + return message.take(MAX_ERROR_MESSAGE_LENGTH) + } + + companion object { + private const val MAX_ERROR_MESSAGE_LENGTH = 512 + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestAlreadyExistsException.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestAlreadyExistsException.kt new file mode 100644 index 0000000..fdc8389 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestAlreadyExistsException.kt @@ -0,0 +1,8 @@ +package org.nstart.dep265.requestservice.service + +import java.util.UUID + +class RequestAlreadyExistsException( + val requestId: UUID, + cause: Throwable? = null, +) : RuntimeException("Request already exists: $requestId", cause) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt new file mode 100644 index 0000000..044c9af --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt @@ -0,0 +1,68 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.databind.ObjectMapper +import org.nstart.dep265.requestservice.entity.OutboxEventStatus +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID + +@Service +class RequestCompletedOutboxService( + private val outboxEventRepository: OutboxEventRepository, + + private val objectMapper: ObjectMapper, + + private val geometryService: GeometryService, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @Transactional(propagation = Propagation.MANDATORY) + fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) { + val completedAt = request.completedAt ?: return + val payload = RequestCompletedPayload( + requestId = request.id, + status = RequestStatus.COMPLETED.name, + completedAt = completedAt, + coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea), + matchedAt = request.lastMatchedAt, + eventCreatedAt = eventCreatedAt, + ) + + val inserted = outboxEventRepository.insertIfAbsent( + id = UUID.randomUUID(), + eventType = REQUEST_COMPLETED_EVENT_TYPE, + aggregateType = REQUEST_AGGREGATE_TYPE, + aggregateId = request.id, + payload = objectMapper.writeValueAsString(payload), + status = OutboxEventStatus.NEW.name, + createdAt = eventCreatedAt, + ) + + if (inserted == 0) { + log.debug( + "REQUEST_COMPLETED outbox event already exists for requestId={}", + request.id, + ) + } + } + + private data class RequestCompletedPayload( + val requestId: UUID, + val status: String, + val completedAt: LocalDateTime, + val coveragePercent: Double, + val matchedAt: LocalDateTime?, + val eventCreatedAt: LocalDateTime, + ) + + companion object { + const val REQUEST_COMPLETED_EVENT_TYPE = "REQUEST_COMPLETED" + const val REQUEST_AGGREGATE_TYPE = "REQUEST" + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt new file mode 100644 index 0000000..fa4db94 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt @@ -0,0 +1,122 @@ +package org.nstart.dep265.requestservice.service + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class RequestGridProjectionService( + private val requestRepository: RequestRepository, + + private val earthCellRepository: EarthCellRepository, + + private val requestCellRepository: RequestCellRepository, + + private val geometryService: GeometryService, + + @Value("\${app.grid.enabled:true}") + private val gridEnabled: Boolean, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun rebuildProjection(requestId: UUID) { + if (!gridEnabled) { + return + } + + val requestEntity = requestRepository.findById(requestId).orElse(null) ?: return + rebuildProjection(requestEntity) + } + + @Transactional + fun rebuildProjection(requestEntity: RequestEntity) { + if (!gridEnabled) { + return + } + + /** Старая проекция удаляется целиком, потому что rebuild должен быть идемпотентным. */ + requestCellRepository.deleteByRequestId(requestEntity.id) + + val projectionGeometry = geometryService.parsePolygonalGeometry(requestEntity.geometry) + + /** Для пересечения с сеткой [0..360] геометрию нужно перевести в непрерывный диапазон. */ + val normalizedProjectionGeometry = LongitudeWrapGeometry.normalizeToContinuous360(projectionGeometry) + + /** Поиск по одному WKT недостаточен для заявок, пересекающих нулевой меридиан. */ + val intersectingCells = LongitudeWrapGeometry.buildSearchVariants(normalizedProjectionGeometry) + .flatMap { projectionVariant -> + earthCellRepository.findIntersectingByContour(geometryService.toWkt(projectionVariant)) + } + .distinctBy { earthCell -> earthCell.cellId ?: earthCell.cellNum } + + val requestCellEntities = mutableListOf() + + intersectingCells.forEach { earthCell -> + val cellGeometry = geometryService.parsePolygonalGeometry(earthCell.contour) + + /** Ячейка выравнивается к полосе долгот заявки, чтобы точное пересечение было корректным. */ + val alignedCellGeometry = LongitudeWrapGeometry.alignToReference(cellGeometry, normalizedProjectionGeometry) + + /** Пересечение заявка ∩ ячейка может дать Polygon, MultiPolygon или пустую геометрию. */ + val intersectionGeometry = geometryService.keepPolygonalGeometry( + normalizedProjectionGeometry.intersection(alignedCellGeometry) + ) + + when (intersectionGeometry) { + is Polygon -> addProjectionPiece(requestCellEntities, requestEntity, earthCell, cellGeometry, intersectionGeometry) + is MultiPolygon -> { + for (geometryIndex in 0 until intersectionGeometry.numGeometries) { + /** Отдельная часть MultiPolygon добавляется как самостоятельный request_cells fragment. */ + val geometryPart = intersectionGeometry.getGeometryN(geometryIndex) + if (geometryPart is Polygon && !geometryPart.isEmpty) { + addProjectionPiece(requestCellEntities, requestEntity, earthCell, cellGeometry, geometryPart) + } + } + } + } + } + + requestCellRepository.saveAll(requestCellEntities) + log.info("Grid projection rebuilt: requestId={}, fragments={}", requestEntity.id, requestCellEntities.size) + } + + private fun addProjectionPiece( + requestCellEntities: MutableList, + requestEntity: RequestEntity, + earthCell: EarthCellEntity, + cellGeometry: Geometry, + intersectionPolygon: Polygon, + ) { + if (intersectionPolygon.isEmpty || cellGeometry.area <= 0.0) { + return + } + + /** Процент покрытия нужен trigger'у importance и compat DTO CellRequestDOT. */ + val coveragePercent = (intersectionPolygon.area / cellGeometry.area) * 100.0 + + val normalizedForCellBand = LongitudeWrapGeometry.alignToReference(intersectionPolygon, cellGeometry) + val intersectionWkt = geometryService.toWkt(normalizedForCellBand) + + val requestCellEntity = RequestCellEntity( + id = null, + requestId = requestEntity.id, + coveragePercent = coveragePercent, + importance = requestEntity.importance, + contour = intersectionWkt, + cell = earthCell, + ) + requestCellEntities.add(requestCellEntity) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt new file mode 100644 index 0000000..1572b6b --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt @@ -0,0 +1,55 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class RequestLifecycleService( + private val geometryService: GeometryService, +) { + fun refreshStatus(request: RequestEntity, now: LocalDateTime): RequestLifecycleRefreshResult { + if (request.status == RequestStatus.DELETED) { + return RequestLifecycleRefreshResult(statusChanged = false, completedNow = false) + } + + val previousStatus: RequestStatus = request.status + val previousCompletedAt = request.completedAt + + val coveredPercent: Double = geometryService.coveredPercent(request.geometryArea, request.remainingArea) + val requiredPercent = request.coverageRequiredPercent + + when { + coveredPercent >= requiredPercent -> { + request.status = RequestStatus.COMPLETED + if (request.completedAt == null) { + request.completedAt = now + } + } + now.isBefore(request.beginDateTime) -> { + request.status = RequestStatus.ACCEPTED + request.completedAt = null + } + now.isAfter(request.endDateTime) -> { + request.status = RequestStatus.EXPIRED + request.completedAt = null + } + else -> { + request.status = RequestStatus.ACTIVE + request.completedAt = null + } + } + + return RequestLifecycleRefreshResult( + statusChanged = previousStatus != request.status || previousCompletedAt != request.completedAt, + completedNow = previousStatus != RequestStatus.COMPLETED && request.status == RequestStatus.COMPLETED, + ) + } +} + +data class RequestLifecycleRefreshResult( + val statusChanged: Boolean, + + val completedNow: Boolean, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt new file mode 100644 index 0000000..6647e3b --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -0,0 +1,358 @@ +package org.nstart.dep265.requestservice.service + +import jakarta.persistence.EntityExistsException +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceException +import org.hibernate.exception.ConstraintViolationException +import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto +import org.nstart.dep265.requestservice.dto.CreateRequestResponseDto +import org.nstart.dep265.requestservice.dto.CoverageStateDto +import org.nstart.dep265.requestservice.dto.DeleteRequestResponseDto +import org.nstart.dep265.requestservice.dto.DeleteRequestStatusDto +import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto +import org.nstart.dep265.requestservice.dto.OpticsParamsDto +import org.nstart.dep265.requestservice.dto.RequestListResponseDto +import org.nstart.dep265.requestservice.dto.RequestCellResponseDto +import org.nstart.dep265.requestservice.dto.RequestResponseDto +import org.nstart.dep265.requestservice.dto.RequestSummaryResponseDto +import org.nstart.dep265.requestservice.dto.RequestWithCellsResponseDto +import org.nstart.dep265.requestservice.dto.RequestStatusDto +import org.nstart.dep265.requestservice.dto.RsaParamsDto +import org.nstart.dep265.requestservice.dto.SurveyTypeDto +import org.nstart.dep265.requestservice.entity.OpticsResultType +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity +import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.entity.RsaPolarisation +import org.nstart.dep265.requestservice.entity.RsaResultType +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +class InvalidRequestListSortException(message: String) : IllegalArgumentException(message) + +@Service +class RequestService( + private val requestRepository: RequestRepository, + private val requestOpticsParamsRepository: RequestOpticsParamsRepository, + private val requestRsaParamsRepository: RequestRsaParamsRepository, + private val requestCellRepository: RequestCellRepository, + private val geometryService: GeometryService, + private val requestGridProjectionService: RequestGridProjectionService, + private val entityManager: EntityManager, +) { + @Transactional + fun createRequest(request: CreateRequestRequestDto): CreateRequestResponseDto { + val requestId = request.id + if (requestRepository.existsById(requestId)) { + throw RequestAlreadyExistsException(requestId) + } + + require(request.beginDateTime.isBefore(request.endDateTime)) { + "beginDateTime должен быть раньше endDateTime" + } + + val now = LocalDateTime.now() + val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry) + val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry) + val geometryArea = geometryService.area(normalizedGeometry) + val surveyType = request.surveyType() + val importance = requireNotNull(request.importance) { "importance обязателен" } + + val requestEntity = RequestEntity( + id = requestId, + name = request.name, + geometry = normalizedGeometryWkt, + remainingGeometry = normalizedGeometryWkt, + geometryArea = geometryArea, + remainingArea = geometryArea, + coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, + importance = importance, + beginDateTime = request.beginDateTime.toUtcLocalDateTime(), + endDateTime = request.endDateTime.toUtcLocalDateTime(), + kpp = request.kpp.toMutableList(), + highPriorityTransmit = request.highPriorityTransmit, + surveyType = surveyType, + status = RequestStatus.ACCEPTED, + createdAt = now, + updatedAt = now, + ) + + persistNewRequest(requestEntity) + replaceSurveyParams(requestEntity.id, request.optics, request.rsa) + requestGridProjectionService.rebuildProjection(requestEntity) + + return CreateRequestResponseDto( + id = requestEntity.id, + name = requestEntity.name, + status = RequestStatusDto.ACCEPTED, + surveyType = requestEntity.surveyType.toDto(), + importance = requestEntity.importance, + message = "Заявка успешно создана", + ) + } + + @Transactional(readOnly = true) + fun listRequests(query: ListRequestsQueryDto): RequestListResponseDto { + val requests = requestRepository.findRequests( + includeDeleted = query.includeDeleted, + deletedStatus = RequestStatus.DELETED, + status = query.status?.toEntity(), + surveyType = query.surveyType?.toEntity(), + kpp = query.kpp, + highPriorityTransmit = query.highPriorityTransmit, + beginFrom = query.beginFrom?.toUtcLocalDateTime(), + beginTo = query.beginTo?.toUtcLocalDateTime(), + endFrom = query.endFrom?.toUtcLocalDateTime(), + endTo = query.endTo?.toUtcLocalDateTime(), + pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()), + ) + return RequestListResponseDto( + items = requests.content.map { request -> request.toSummaryResponse() }, + page = query.page, + size = query.size, + totalItems = requests.totalElements, + totalPages = requests.totalPages, + ) + } + + @Transactional(readOnly = true) + fun getRequestById(id: UUID): RequestResponseDto? { + val request = requestRepository.findById(id).orElse(null) ?: return null + if (request.status == RequestStatus.DELETED) { + return null + } + return request.toResponse() + } + + @Transactional(readOnly = true) + fun getRequestWithCells(id: UUID): RequestWithCellsResponseDto? { + val request = requestRepository.findById(id).orElse(null) ?: return null + val cells = requestCellRepository.findWithCellByRequestId(id) + .map { requestCell -> requestCell.toRequestCellResponse() } + + return RequestWithCellsResponseDto( + request = request.toResponse(), + cells = cells, + ) + } + + @Transactional + fun deleteRequest(id: UUID): DeleteRequestResponseDto? { + val request = requestRepository.findById(id).orElse(null) ?: return null + val deletedAt = request.deletedAt ?: LocalDateTime.now() + request.status = RequestStatus.DELETED + request.deletedAt = deletedAt + request.updatedAt = deletedAt + requestRepository.save(request) + requestCellRepository.deleteByRequestId(request.id) + return DeleteRequestResponseDto( + id = request.id, + status = DeleteRequestStatusDto.DELETED, + deletedAt = deletedAt.toOffsetDateTime(), + ) + } + + private fun persistNewRequest(requestEntity: RequestEntity) { + try { + entityManager.persist(requestEntity) + entityManager.flush() + } catch (exception: EntityExistsException) { + throw RequestAlreadyExistsException(requestEntity.id, exception) + } catch (exception: PersistenceException) { + if (exception.isDuplicateRequestId()) { + throw RequestAlreadyExistsException(requestEntity.id, exception) + } + throw exception + } + } + + private fun replaceSurveyParams(requestId: UUID, optics: OpticsParamsDto?, rsa: RsaParamsDto?) { + if (optics == null) { + if (requestOpticsParamsRepository.existsById(requestId)) { + requestOpticsParamsRepository.deleteById(requestId) + } + } else { + requestOpticsParamsRepository.save(optics.toEntity(requestId)) + } + + if (rsa == null) { + if (requestRsaParamsRepository.existsById(requestId)) { + requestRsaParamsRepository.deleteById(requestId) + } + } else { + requestRsaParamsRepository.save(rsa.toEntity(requestId)) + } + } + + private fun RequestEntity.toResponse(): RequestResponseDto { + val optics = requestOpticsParamsRepository.findById(id).orElse(null)?.toDto() + val rsa = requestRsaParamsRepository.findById(id).orElse(null)?.toDto() + return RequestResponseDto( + id = id, + name = name, + status = status.toDto(), + surveyType = surveyType.toDto(), + geometry = geometry, + importance = importance, + beginDateTime = beginDateTime.toOffsetDateTime(), + endDateTime = endDateTime.toOffsetDateTime(), + kpp = kpp, + highPriorityTransmit = highPriorityTransmit, + optics = optics, + rsa = rsa, + coverage = coverage(), + createdAt = createdAt.toOffsetDateTime(), + updatedAt = updatedAt.toOffsetDateTime(), + deletedAt = deletedAt?.toOffsetDateTime(), + ) + } + + private fun RequestCellEntity.toRequestCellResponse(): RequestCellResponseDto { + return RequestCellResponseDto( + cellNum = requireNotNull(cell?.cellNum) { "request_cell must be linked to earth_cell" }, + coveragePercent = coveragePercent ?: 0.0, + importance = importance, + contour = contour, + ) + } + + private fun RequestEntity.toSummaryResponse(): RequestSummaryResponseDto { + return RequestSummaryResponseDto( + id = id, + name = name, + status = status.toDto(), + surveyType = surveyType.toDto(), + importance = importance, + beginDateTime = beginDateTime.toOffsetDateTime(), + endDateTime = endDateTime.toOffsetDateTime(), + kpp = kpp, + highPriorityTransmit = highPriorityTransmit, + coveragePercent = coverage().currentPercent, + createdAt = createdAt.toOffsetDateTime(), + updatedAt = updatedAt.toOffsetDateTime(), + deletedAt = deletedAt?.toOffsetDateTime(), + ) + } + + private fun RequestEntity.coverage(): CoverageStateDto { + return CoverageStateDto( + requiredPercent = coverageRequiredPercent, + currentPercent = geometryService.coveredPercent(geometryArea, remainingArea), + ) + } + + private fun CreateRequestRequestDto.surveyType(): RequestSurveyType { + return when { + optics != null && rsa != null -> RequestSurveyType.COMBINED + optics != null -> RequestSurveyType.OPTICS + rsa != null -> RequestSurveyType.RSA + else -> throw IllegalArgumentException("Должен быть указан хотя бы один блок съемки: optics или rsa") + } + } + + private fun OpticsParamsDto.toEntity(requestId: UUID): RequestOpticsParamsEntity { + return RequestOpticsParamsEntity( + requestId = requestId, + resultType = OpticsResultType.valueOf(resultType.name), + resolution = resolution, + sunAngleMin = sunAngleMin, + sunAngleMax = sunAngleMax, + clouds = clouds, + ) + } + + private fun RequestOpticsParamsEntity.toDto(): OpticsParamsDto { + return OpticsParamsDto( + resultType = org.nstart.dep265.requestservice.dto.OpticsResultTypeDto.valueOf(resultType.name), + resolution = resolution, + sunAngleMin = sunAngleMin, + sunAngleMax = sunAngleMax, + clouds = clouds, + ) + } + + private fun RsaParamsDto.toEntity(requestId: UUID): RequestRsaParamsEntity { + return RequestRsaParamsEntity( + requestId = requestId, + resultType = RsaResultType.valueOf(resultType.name), + interferometry = interferometry, + polarisation = RsaPolarisation.valueOf(polarisation.name), + resolution = resolution, + ) + } + + private fun RequestRsaParamsEntity.toDto(): RsaParamsDto { + return RsaParamsDto( + resultType = org.nstart.dep265.requestservice.dto.RsaResultTypeDto.valueOf(resultType.name), + interferometry = interferometry, + polarisation = org.nstart.dep265.requestservice.dto.PolarisationDto.valueOf(polarisation.name), + resolution = resolution, + ) + } + + private fun RequestStatus.toDto(): RequestStatusDto = RequestStatusDto.valueOf(name) + + private fun RequestSurveyType.toDto(): SurveyTypeDto = SurveyTypeDto.valueOf(name) + + private fun RequestStatusDto.toEntity(): RequestStatus = RequestStatus.valueOf(name) + + private fun SurveyTypeDto.toEntity(): RequestSurveyType = RequestSurveyType.valueOf(name) + + private fun String.toRequestSort(): Sort { + val parts = split(",").map { part -> part.trim() }.filter { part -> part.isNotEmpty() } + val field = parts.firstOrNull() ?: DEFAULT_SORT_FIELD + val direction = parts.getOrNull(1)?.let { value -> + when (value.lowercase()) { + "asc" -> Sort.Direction.ASC + "desc" -> Sort.Direction.DESC + else -> throw InvalidRequestListSortException("Unsupported sort direction: $value") + } + } ?: DEFAULT_SORT_DIRECTION + val property = ALLOWED_SORT_FIELDS[field] + ?: throw InvalidRequestListSortException("Unsupported sort field: $field") + return Sort.by(direction, property) + } + + private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + + private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC) + + private fun Throwable.isDuplicateRequestId(): Boolean { + return generateSequence(this) { throwable -> throwable.cause } + .filterIsInstance() + .any { exception -> + exception.sqlState == POSTGRES_UNIQUE_VIOLATION && + (exception.constraintName == null || exception.constraintName == REQUESTS_PRIMARY_KEY) + } + } + + private companion object { + const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0 + const val POSTGRES_UNIQUE_VIOLATION = "23505" + const val REQUESTS_PRIMARY_KEY = "requests_pkey" + const val DEFAULT_SORT_FIELD = "createdAt" + val DEFAULT_SORT_DIRECTION: Sort.Direction = Sort.Direction.DESC + val ALLOWED_SORT_FIELDS: Map = mapOf( + "createdAt" to "createdAt", + "updatedAt" to "updatedAt", + "beginDateTime" to "beginDateTime", + "endDateTime" to "endDateTime", + "status" to "status", + "surveyType" to "surveyType", + "importance" to "importance", + ) + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt new file mode 100644 index 0000000..e5f6d8e --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt @@ -0,0 +1,36 @@ +package org.nstart.dep265.requestservice.service + +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.nstart.dep265.requestservice.dto.RouteDto +import org.springframework.stereotype.Service + +@Service +class RequestValidationService( + private val geometryService: GeometryService, +) { + private val maxAllowedRollAngle: Double = 90.0 + + fun validate(route: RouteDto) { + require(route.intervalBegin.isBefore(route.intervalEnd)) { + "Начало интервала маршрута должно быть раньше окончания" + } + require(route.rollAngle.min >= 0.0) { + "Минимальный крен маршрута не может быть отрицательным" + } + require(route.rollAngle.max <= maxAllowedRollAngle) { + "Максимальный крен маршрута должен быть в диапазоне от 0 до $maxAllowedRollAngle градусов" + } + require(route.rollAngle.min <= route.rollAngle.max) { + "Диапазон крена маршрута задан некорректно" + } + + val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry) + require(routeGeometry is Polygon || routeGeometry is MultiPolygon) { + "Геометрия маршрута должна быть POLYGON или MULTIPOLYGON в формате WKT" + } + require(routeGeometry.area > 0.0) { + "Геометрия маршрута должна иметь положительную площадь" + } + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt new file mode 100644 index 0000000..0fb6ed7 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt @@ -0,0 +1,230 @@ +package org.nstart.dep265.requestservice.service + +import org.locationtech.jts.geom.Geometry +import org.nstart.dep265.requestservice.dto.RouteDto +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.nstart.dep265.requestservice.repository.RequestRouteMatchRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.dao.DuplicateKeyException +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime +import java.util.UUID +import kotlin.math.max +import kotlin.math.min + +@Service +class RouteMatchingService( + private val requestRepository: RequestRepository, + + private val requestRouteMatchRepository: RequestRouteMatchRepository, + + private val requestValidationService: RequestValidationService, + + private val geometryService: GeometryService, + + private val requestLifecycleService: RequestLifecycleService, + + private val requestCompletedOutboxService: RequestCompletedOutboxService, + + @param:Qualifier("routeMatchingTransactionOperations") + private val routeMatchingTransactionOperations: TransactionOperations, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + fun process(route: RouteDto) { + requestValidationService.validate(route) + + val routeId = route.routeId + val routeRollRange = route.rollAngle + val matchedAt: LocalDateTime = LocalDateTime.now() + val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry) + + val candidateRequestIds = requestRepository.findRequestIdsForRoute( + statuses = listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE), + routeBegin = route.intervalBegin, + routeEnd = route.intervalEnd, + ) + + var actualMatches = 0 + + candidateRequestIds.forEach { requestId -> + if (requestRouteMatchRepository.existsByRequestIdAndRouteId(requestId, routeId)) { + return@forEach + } + + try { + val contributed = routeMatchingTransactionOperations.execute( + TransactionCallback { + applyRouteToRequest( + requestId = requestId, + routeId = routeId, + routeGeometry = routeGeometry, + matchedAt = matchedAt, + ) + }, + ) ?: false + + if (contributed) { + actualMatches += 1 + } + } catch (exception: DataIntegrityViolationException) { + if (!exception.isDuplicateRouteMatchViolation()) { + throw exception + } + log.debug( + "Route match already exists after concurrent insert: requestId={}, routeId={}", + requestId, + routeId, + ) + } + } + + log.info( + "Route processed: routeId={}, candidatesMatched={}, actualMatches={}, roll={}", + routeId, + candidateRequestIds.size, + actualMatches, + routeRollRange, + ) + } + + private fun applyRouteToRequest( + requestId: UUID, + routeId: UUID, + routeGeometry: Geometry, + matchedAt: LocalDateTime, + ): Boolean { + val request = requestRepository.findByIdForUpdate(requestId).orElse(null) ?: return false + + if (request.status !in ACTIVE_ROUTE_MATCHING_STATUSES || request.deletedAt != null) { + return false + } + + if (requestRouteMatchRepository.existsByRequestIdAndRouteId(request.id, routeId)) { + return false + } + + val originalGeometry = geometryService.parsePolygonalGeometry(request.geometry) + val originalIntersection = geometryService.keepPolygonalGeometry(originalGeometry.intersection(routeGeometry)) + val originalIntersectionArea = geometryService.area(originalIntersection) + + if (!originalIntersection.hasPositiveArea(originalIntersectionArea)) { + return false + } + + val remainingGeometry = geometryService.parsePolygonalGeometry(request.remainingGeometry) + val remainingIntersection = geometryService.keepPolygonalGeometry(remainingGeometry.intersection(routeGeometry)) + val remainingIntersectionArea = geometryService.area(remainingIntersection) + + if (!remainingIntersection.hasPositiveArea(remainingIntersectionArea)) { + saveRouteMatch( + request = request, + routeId = routeId, + originalIntersection = originalIntersection, + originalIntersectionArea = originalIntersectionArea, + appliedIntersection = null, + appliedIntersectionArea = 0.0, + coverageDeltaPercent = 0.0, + contributesToCoverage = false, + matchedAt = matchedAt, + ) + return false + } + + val updatedRemainingGeometry = geometryService.keepPolygonalGeometry( + remainingGeometry.difference(remainingIntersection), + ) + val updatedRemainingArea = max(geometryService.area(updatedRemainingGeometry), 0.0) + val coverageDeltaPercent = coverageDeltaPercent( + originalArea = request.geometryArea, + appliedIntersectionArea = remainingIntersectionArea, + ) + + saveRouteMatch( + request = request, + routeId = routeId, + originalIntersection = originalIntersection, + originalIntersectionArea = originalIntersectionArea, + appliedIntersection = remainingIntersection, + appliedIntersectionArea = remainingIntersectionArea, + coverageDeltaPercent = coverageDeltaPercent, + contributesToCoverage = true, + matchedAt = matchedAt, + ) + + request.remainingGeometry = geometryService.toWkt(updatedRemainingGeometry) + request.remainingArea = updatedRemainingArea + request.matchCount = request.matchCount + 1 + request.lastMatchedAt = matchedAt + request.updatedAt = matchedAt + + val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt) + requestRepository.save(request) + if (statusRefresh.completedNow) { + requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt) + } + return true + } + + private fun saveRouteMatch( + request: RequestEntity, + routeId: UUID, + originalIntersection: Geometry, + originalIntersectionArea: Double, + appliedIntersection: Geometry?, + appliedIntersectionArea: Double, + coverageDeltaPercent: Double, + contributesToCoverage: Boolean, + matchedAt: LocalDateTime, + ) { + val requestRouteMatch = RequestRouteMatchEntity( + requestId = request.id, + routeId = routeId, + originalIntersectionGeometry = geometryService.toWkt(originalIntersection), + originalIntersectionArea = originalIntersectionArea, + appliedIntersectionGeometry = appliedIntersection?.let { geometryService.toWkt(it) }, + appliedIntersectionArea = appliedIntersectionArea, + coverageDeltaPercent = coverageDeltaPercent, + contributesToCoverage = contributesToCoverage, + matchedAt = matchedAt, + ) + + requestRouteMatchRepository.saveAndFlush(requestRouteMatch) + } + + private fun coverageDeltaPercent(originalArea: Double, appliedIntersectionArea: Double): Double { + if (originalArea <= 0.0) { + return 0.0 + } + + val percent = (appliedIntersectionArea / originalArea) * 100.0 + return min(max(percent, 0.0), 100.0) + } + + private fun Geometry.hasPositiveArea(area: Double): Boolean = !isEmpty && area > 0.0 + + private fun DataIntegrityViolationException.isDuplicateRouteMatchViolation(): Boolean { + if (this is DuplicateKeyException) { + return true + } + + val message = listOfNotNull(message, mostSpecificCause.message) + .joinToString(separator = " ") + .lowercase() + + return message.contains("uk_request_route_match") || + message.contains("request_id, route_id") || + (message.contains("duplicate") && message.contains("unique")) + } + + companion object { + private val ACTIVE_ROUTE_MATCHING_STATUSES = setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE) + } +} diff --git a/services/pcp-request-service/src/main/resources/application.yaml b/services/pcp-request-service/src/main/resources/application.yaml new file mode 100644 index 0000000..c3632e3 --- /dev/null +++ b/services/pcp-request-service/src/main/resources/application.yaml @@ -0,0 +1,19 @@ +spring: + application: + name: pcp-request-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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} + +pcp: + outbox: + request-completed-topic: pcp.request.completed.v1 + publish-batch-size: 50 + publish-fixed-delay-ms: 5000 diff --git a/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql b/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql new file mode 100644 index 0000000..96df62e --- /dev/null +++ b/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql @@ -0,0 +1,210 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + +CREATE TABLE IF NOT EXISTS requests ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + geometry TEXT NOT NULL, + remaining_geometry TEXT NOT NULL, + geometry_area DOUBLE PRECISION NOT NULL, + remaining_area DOUBLE PRECISION NOT NULL, + coverage_required_percent DOUBLE PRECISION NOT NULL DEFAULT 100.0, + importance DOUBLE PRECISION NOT NULL, + begin_date_time TIMESTAMP NOT NULL, + end_date_time TIMESTAMP NOT NULL, + high_priority_transmit BOOLEAN NOT NULL DEFAULT false, + survey_type VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + match_count INTEGER NOT NULL DEFAULT 0, + last_matched_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_requests_status + CHECK (status IN ('ACCEPTED', 'ACTIVE', 'COMPLETED', 'EXPIRED', 'DELETED')), + CONSTRAINT chk_requests_survey_type + CHECK (survey_type IN ('OPTICS', 'RSA', 'COMBINED')), + CONSTRAINT chk_requests_time_window + CHECK (begin_date_time < end_date_time), + CONSTRAINT chk_requests_importance_non_negative + CHECK (importance >= 0), + CONSTRAINT chk_requests_name_not_blank + CHECK (length(trim(name)) > 0), + CONSTRAINT chk_requests_soft_delete + CHECK ((status = 'DELETED' AND deleted_at IS NOT NULL) OR (status <> 'DELETED')) +); + +CREATE TABLE IF NOT EXISTS request_kpp ( + request_id UUID NOT NULL, + kpp INTEGER NOT NULL, + CONSTRAINT fk_request_kpp_request + FOREIGN KEY (request_id) + REFERENCES requests(id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS request_optics_params ( + request_id UUID PRIMARY KEY, + result_type VARCHAR(32) NOT NULL, + resolution DOUBLE PRECISION NOT NULL, + sun_angle_min DOUBLE PRECISION NOT NULL DEFAULT 10.0, + sun_angle_max DOUBLE PRECISION NOT NULL DEFAULT 90.0, + clouds DOUBLE PRECISION NOT NULL DEFAULT 100.0, + CONSTRAINT fk_request_optics_params_request + FOREIGN KEY (request_id) + REFERENCES requests(id) + ON DELETE CASCADE, + CONSTRAINT chk_request_optics_result_type + CHECK (result_type IN ('PANCHROMATIC', 'MULTISPECTRAL', 'PANSHARPENING')) +); + +CREATE TABLE IF NOT EXISTS request_rsa_params ( + request_id UUID PRIMARY KEY, + result_type VARCHAR(32) NOT NULL, + interferometry BOOLEAN NOT NULL DEFAULT false, + polarisation VARCHAR(8) NOT NULL, + resolution DOUBLE PRECISION NOT NULL, + CONSTRAINT fk_request_rsa_params_request + FOREIGN KEY (request_id) + REFERENCES requests(id) + ON DELETE CASCADE, + CONSTRAINT chk_request_rsa_result_type + CHECK (result_type IN ('SPOTLIGHT', 'STRIPMAP', 'SCANSAR')), + CONSTRAINT chk_request_rsa_polarisation + CHECK (polarisation IN ('HH', 'VV')) +); + +CREATE TABLE IF NOT EXISTS request_route_matches ( + id BIGSERIAL PRIMARY KEY, + request_id UUID NOT NULL, + route_id UUID NOT NULL, + original_intersection_geometry TEXT NOT NULL, + original_intersection_area DOUBLE PRECISION NOT NULL, + applied_intersection_geometry TEXT NULL, + applied_intersection_area DOUBLE PRECISION NOT NULL DEFAULT 0.0, + coverage_delta_percent DOUBLE PRECISION NOT NULL DEFAULT 0.0, + contributes_to_coverage BOOLEAN NOT NULL DEFAULT false, + matched_at TIMESTAMP NOT NULL, + CONSTRAINT fk_request_route_matches_request + FOREIGN KEY (request_id) + REFERENCES requests(id) + ON DELETE CASCADE, + CONSTRAINT uk_request_route_match UNIQUE (request_id, route_id) +); + +CREATE TABLE IF NOT EXISTS outbox_events ( + id UUID PRIMARY KEY, + event_type VARCHAR(128) NOT NULL, + aggregate_type VARCHAR(64) NOT NULL, + aggregate_id UUID NOT NULL, + payload JSONB NOT NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + published_at TIMESTAMP NULL, + error_message TEXT NULL, + CONSTRAINT chk_outbox_events_status + CHECK (status IN ('NEW', 'PUBLISHED', 'FAILED')), + CONSTRAINT uk_outbox_events_event_type_aggregate_id + UNIQUE (event_type, aggregate_id) +); + +CREATE TABLE IF NOT EXISTS earth_cells ( + cell_id BIGSERIAL PRIMARY KEY, + cell_num BIGINT NOT NULL, + longitude DOUBLE PRECISION, + latitude DOUBLE PRECISION, + importance DOUBLE PRECISION NOT NULL DEFAULT 0.0, + contour_wkt TEXT NOT NULL, + contour_geom geometry(Polygon, 4326) +); + +CREATE TABLE IF NOT EXISTS request_cells ( + id BIGSERIAL PRIMARY KEY, + request_id UUID NOT NULL, + cell_id BIGINT NOT NULL, + coverage_percent DOUBLE PRECISION, + importance DOUBLE PRECISION NOT NULL, + contour_wkt TEXT NOT NULL, + contour_geom geometry(Polygon, 4326), + CONSTRAINT fk_request_cells_request + FOREIGN KEY (request_id) + REFERENCES requests(id) + ON DELETE CASCADE, + CONSTRAINT fk_request_cells_cell + FOREIGN KEY (cell_id) + REFERENCES earth_cells(cell_id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS earth_grid_settings ( + settings_id BIGINT PRIMARY KEY, + calculation_step DOUBLE PRECISION NOT NULL +); + +INSERT INTO earth_grid_settings(settings_id, calculation_step) +VALUES (1, 2.0) +ON CONFLICT (settings_id) DO NOTHING; + +CREATE OR REPLACE FUNCTION fill_earth_cell_geometry() +RETURNS TRIGGER AS $$ +BEGIN + NEW.contour_geom := ST_GeomFromText(NEW.contour_wkt, 4326)::geometry(Polygon, 4326); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fill_request_cell_geometry() +RETURNS TRIGGER AS $$ +BEGIN + NEW.contour_geom := ST_GeomFromText(NEW.contour_wkt, 4326)::geometry(Polygon, 4326); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION update_earth_cell_importance() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE earth_cells earth_cell + SET importance = ( + SELECT COALESCE(SUM(request_cell.importance * (COALESCE(request_cell.coverage_percent, 0.0) / 100.0)), 0.0) + FROM request_cells request_cell + WHERE request_cell.cell_id = COALESCE(NEW.cell_id, OLD.cell_id) + ) + WHERE earth_cell.cell_id = COALESCE(NEW.cell_id, OLD.cell_id); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_fill_earth_cell_geometry ON earth_cells; +CREATE TRIGGER trg_fill_earth_cell_geometry +BEFORE INSERT OR UPDATE OF contour_wkt ON earth_cells +FOR EACH ROW EXECUTE FUNCTION fill_earth_cell_geometry(); + +DROP TRIGGER IF EXISTS trg_fill_request_cell_geometry ON request_cells; +CREATE TRIGGER trg_fill_request_cell_geometry +BEFORE INSERT OR UPDATE OF contour_wkt ON request_cells +FOR EACH ROW EXECUTE FUNCTION fill_request_cell_geometry(); + +DROP TRIGGER IF EXISTS request_cells_importance_trigger ON request_cells; +CREATE TRIGGER request_cells_importance_trigger +AFTER INSERT OR UPDATE OR DELETE ON request_cells +FOR EACH ROW EXECUTE FUNCTION update_earth_cell_importance(); + +CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); +CREATE INDEX IF NOT EXISTS idx_requests_survey_type ON requests(survey_type); +CREATE INDEX IF NOT EXISTS idx_requests_begin_date_time ON requests(begin_date_time); +CREATE INDEX IF NOT EXISTS idx_requests_end_date_time ON requests(end_date_time); +CREATE INDEX IF NOT EXISTS idx_requests_deleted_at ON requests(deleted_at); +CREATE INDEX IF NOT EXISTS idx_requests_completed_at ON requests(completed_at); +CREATE INDEX IF NOT EXISTS idx_requests_cleanup_status_end ON requests(status, end_date_time); +CREATE INDEX IF NOT EXISTS idx_request_kpp_request_id ON request_kpp(request_id); +CREATE INDEX IF NOT EXISTS idx_request_kpp_kpp ON request_kpp(kpp); +CREATE INDEX IF NOT EXISTS idx_request_route_matches_route_id ON request_route_matches(route_id); +CREATE INDEX IF NOT EXISTS idx_outbox_events_status_created_at ON outbox_events(status, created_at); +CREATE INDEX IF NOT EXISTS idx_earth_cells_cell_num ON earth_cells(cell_num); +CREATE INDEX IF NOT EXISTS idx_earth_cells_contour_geom ON earth_cells USING GIST (contour_geom); +CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id); +CREATE INDEX IF NOT EXISTS idx_request_cells_cell_id ON request_cells(cell_id); +CREATE INDEX IF NOT EXISTS idx_request_cells_contour_geom ON request_cells USING GIST (contour_geom); diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt new file mode 100644 index 0000000..7fb6226 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt @@ -0,0 +1,97 @@ +package org.nstart.dep265.requestservice + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MigrationSchemaTest { + @Test + fun `request service has a single fresh schema migration`() { + val migrations = migrationDir() + .toFile() + .listFiles { file -> file.isFile && file.name.endsWith(".sql") } + .orEmpty() + .map { it.name } + .sorted() + + assertEquals(listOf("V1__create_schema.sql"), migrations) + } + + @Test + fun `fresh schema defines mandatory cell importance columns`() { + val schema = migration("V1__create_schema.sql").normalizedSql() + val requests = schema.tableBlock("requests") + val earthCells = schema.tableBlock("earth_cells") + val requestCells = schema.tableBlock("request_cells") + + assertTrue(requests.contains("name varchar(255) not null")) + assertTrue(requests.contains("constraint chk_requests_name_not_blank check (length(trim(name)) > 0)")) + assertTrue(earthCells.contains("importance double precision not null default 0.0")) + assertTrue(requestCells.contains("importance double precision not null")) + assertTrue(!requestCells.contains("importance double precision not null default")) + } + + @Test + fun `earth cell importance trigger writes numeric zero when no request cells remain`() { + val schema = migration("V1__create_schema.sql").normalizedSql() + + assertTrue(schema.contains("after insert or update or delete on request_cells")) + assertTrue(schema.contains("coalesce(sum(request_cell.importance * (coalesce(request_cell.coverage_percent, 0.0) / 100.0)), 0.0)")) + } + + @Test + fun `outbox schema stores payload as jsonb and deduplicates request completion events`() { + val schema = migration("V1__create_schema.sql").normalizedSql() + val outboxEvents = schema.tableBlock("outbox_events") + + assertTrue(outboxEvents.contains("payload jsonb not null")) + assertTrue(outboxEvents.contains("status varchar(32) not null")) + assertTrue(outboxEvents.contains("constraint uk_outbox_events_event_type_aggregate_id unique (event_type, aggregate_id)")) + assertTrue(schema.contains("on outbox_events(status, created_at)")) + } + + @Test + fun `request route matches schema stores suitability and applied coverage separately`() { + val schema = migration("V1__create_schema.sql").normalizedSql() + val requestRouteMatches = schema.tableBlock("request_route_matches") + + assertTrue(requestRouteMatches.contains("original_intersection_geometry text not null")) + assertTrue(requestRouteMatches.contains("original_intersection_area double precision not null")) + assertTrue(requestRouteMatches.contains("applied_intersection_geometry text null")) + assertTrue(requestRouteMatches.contains("applied_intersection_area double precision not null default 0.0")) + assertTrue(requestRouteMatches.contains("coverage_delta_percent double precision not null default 0.0")) + assertTrue(requestRouteMatches.contains("contributes_to_coverage boolean not null default false")) + assertTrue(requestRouteMatches.contains("constraint uk_request_route_match unique (request_id, route_id)")) + } + + private fun migration(fileName: String): Path { + val modulePath = Path.of("src/main/resources/db/migration", fileName) + if (Files.exists(modulePath)) { + return modulePath + } + return Path.of("services/pcp-request-service/src/main/resources/db/migration", fileName) + } + + private fun migrationDir(): Path { + val modulePath = Path.of("src/main/resources/db/migration") + if (Files.exists(modulePath)) { + return modulePath + } + return Path.of("services/pcp-request-service/src/main/resources/db/migration") + } + + private fun Path.normalizedSql(): String = + readText() + .lowercase() + .replace(Regex("\\s+"), " ") + + private fun String.tableBlock(tableName: String): String { + val start = indexOf("create table if not exists $tableName") + require(start >= 0) { "Table $tableName is not defined" } + val nextTable = indexOf("create table if not exists", start + 1).takeIf { it >= 0 } ?: length + return substring(start, nextTable) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/CellsControllerTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/CellsControllerTest.kt new file mode 100644 index 0000000..90c921e --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/CellsControllerTest.kt @@ -0,0 +1,208 @@ +package org.nstart.dep265.requestservice.controller + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.isNull +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.nstart.dep265.requestservice.service.CellsQueryService +import org.nstart.dep265.requestservice.service.CompatGeometryService +import org.nstart.dep265.requestservice.service.EarthGridCatalogService +import org.nstart.dep265.requestservice.service.GeometryService +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.http.MediaType +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.Optional +import java.util.UUID + +class CellsControllerTest { + private val requestId = UUID.fromString("00000000-0000-0000-0000-000000000301") + private val cellWithRequest = EarthCellEntity( + cellNum = 42, + latitude = 55.0, + longitude = 37.0, + importance = 10.0, + contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ).also { earthCell -> + earthCell.requestProjections = mutableListOf( + RequestCellEntity( + requestId = requestId, + contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", + coveragePercent = 25.0, + importance = 7.0, + cell = earthCell, + ) + ) + } + + private val earthCellRepository = mock(EarthCellRepository::class.java).also { repository -> + doReturn( + PageImpl( + listOf(cellWithRequest), + PageRequest.of(0, 50), + 1, + ) + ) + .`when`(repository) + .findCells(isNull(), pageableMatcher()) + doReturn( + listOf( + cellWithRequest, + EarthCellEntity( + cellNum = 43, + latitude = 54.0, + longitude = 38.0, + importance = 0.0, + contour = "POLYGON ((37 54, 38 54, 38 55, 37 55, 37 54))", + ) + ) + ) + .`when`(repository) + .findCellsWithRequestProjections(isNull()) + } + + private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository -> + doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + .`when`(repository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + } + + private val earthGridCatalogService = EarthGridCatalogService( + earthCellRepository = earthCellRepository, + earthGridSettingsRepository = earthGridSettingsRepository, + compatGeometryService = CompatGeometryService(GeometryService()), + gridEnabled = false, + ) + + private val mockMvc = MockMvcBuilders + .standaloneSetup(CellsController(CellsQueryService(earthCellRepository, earthGridCatalogService))) + .setControllerAdvice(CellsApiExceptionHandler()) + .setMessageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper())) + .build() + + @Test + fun `get v1 cells returns pageable list response`() { + mockMvc.perform(get("/v1/cells").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items").isArray) + .andExpect(jsonPath("$.items[0].cellNum").value(42)) + .andExpect(jsonPath("$.items[0].latitude").value(55.0)) + .andExpect(jsonPath("$.items[0].longitude").value(37.0)) + .andExpect(jsonPath("$.items[0].importance").value(10.0)) + .andExpect(jsonPath("$.items[0].contour").value("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(50)) + .andExpect(jsonPath("$.totalItems").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)) + } + + @Test + fun `get v1 cells rejects unsupported sort field`() { + mockMvc.perform( + get("/v1/cells") + .queryParam("sort", "geometry,asc") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("Unsupported sort field: geometry")) + } + + @Test + fun `get v1 cells rejects countLat before aggregation implementation`() { + mockMvc.perform( + get("/v1/cells") + .queryParam("countLat", "2") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("Cell aggregation by countLat/countLong is not implemented yet")) + } + + @Test + fun `get v1 cells with requests returns request fragments`() { + mockMvc.perform(get("/v1/cells/with-requests").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items").isArray) + .andExpect(jsonPath("$.items[0].cellNum").value(42)) + .andExpect(jsonPath("$.items[0].latitude").value(55.0)) + .andExpect(jsonPath("$.items[0].longitude").value(37.0)) + .andExpect(jsonPath("$.items[0].importance").value(10.0)) + .andExpect(jsonPath("$.items[0].contour").value("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))")) + .andExpect(jsonPath("$.items[0].requests[0].requestId").value(requestId.toString())) + .andExpect(jsonPath("$.items[0].requests[0].contour").value("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))")) + .andExpect(jsonPath("$.items[0].requests[0].coveragePercent").value(25.0)) + .andExpect(jsonPath("$.items[0].requests[0].importance").value(7.0)) + .andExpect(jsonPath("$.items[1].requests").isEmpty) + } + + @Test + fun `get v1 cells with requests rejects partial aggregation parameters`() { + mockMvc.perform( + get("/v1/cells/with-requests") + .queryParam("countLat", "2") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("countLat and countLong must be provided together")) + } + + @Test + fun `get v1 cells with requests rejects invalid numeric parameters`() { + mockMvc.perform( + get("/v1/cells/with-requests") + .queryParam("minImportance", "-1") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("minImportance must be greater than or equal to 0")) + + mockMvc.perform( + get("/v1/cells/with-requests") + .queryParam("countLat", "0") + .queryParam("countLong", "1") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("countLat must be greater than 0")) + } + + @Test + fun `get v1 cells with requests accepts complete aggregation parameters`() { + mockMvc.perform( + get("/v1/cells/with-requests") + .queryParam("countLat", "1") + .queryParam("countLong", "1") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items").isArray) + } + + @Test + fun `legacy earth grid with requests endpoint is not restored`() { + mockMvc.perform(get("/api/earth-grid/with-requests").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound) + } + + private fun pageableMatcher(): Pageable { + any(Pageable::class.java) + return PageRequest.of(0, 50) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt new file mode 100644 index 0000000..71ef3cd --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt @@ -0,0 +1,127 @@ +package org.nstart.dep265.requestservice.controller + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.nstart.dep265.requestservice.service.GridRebuildAlreadyRunningException +import org.nstart.dep265.requestservice.service.GridRebuildService +import org.nstart.dep265.requestservice.service.GridSettingsService +import org.springframework.http.MediaType +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.time.OffsetDateTime +import java.util.Optional + +class GridManagementControllerTest { + private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository -> + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(repository) + .save(any(EarthGridSettingsEntity::class.java)) + } + private val gridRebuildService = mock(GridRebuildService::class.java) + + private val mockMvc = MockMvcBuilders + .standaloneSetup(GridManagementController(GridSettingsService(earthGridSettingsRepository), gridRebuildService)) + .setControllerAdvice(GridManagementApiExceptionHandler()) + .setMessageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper().findAndRegisterModules())) + .build() + + @Test + fun `get v1 grid settings returns current settings`() { + doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 4.0))) + .`when`(earthGridSettingsRepository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + + mockMvc.perform(get("/v1/grid/settings").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.settingsId").value(1)) + .andExpect(jsonPath("$.calculationStep").value(4.0)) + } + + @Test + fun `get v1 grid settings creates and returns default settings when missing`() { + doReturn(Optional.empty()) + .`when`(earthGridSettingsRepository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + + mockMvc.perform(get("/v1/grid/settings").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.settingsId").value(1)) + .andExpect(jsonPath("$.calculationStep").value(2.0)) + } + + @Test + fun `put v1 grid settings updates settings`() { + doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + .`when`(earthGridSettingsRepository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + + mockMvc.perform( + put("/v1/grid/settings") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content("""{"calculationStep": 3.5}"""), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.settingsId").value(1)) + .andExpect(jsonPath("$.calculationStep").value(3.5)) + } + + @Test + fun `put v1 grid settings rejects invalid settings request`() { + mockMvc.perform( + put("/v1/grid/settings") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content("""{"calculationStep": 0.0}"""), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `post v1 grid rebuild returns rebuild result`() { + doReturn( + GridRebuildResponseDto( + cellsCount = 2, + rebuiltRequestProjectionsCount = 2, + rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"), + ) + ).`when`(gridRebuildService).rebuildGrid() + + mockMvc.perform(post("/v1/grid/rebuild").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.cellsCount").value(2)) + .andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2)) + .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z")) + } + + @Test + fun `post v1 grid rebuild returns conflict when lock is busy`() { + doThrow(GridRebuildAlreadyRunningException()) + .`when`(gridRebuildService) + .rebuildGrid() + + mockMvc.perform(post("/v1/grid/rebuild").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("GRID_REBUILD_ALREADY_RUNNING")) + } + + @Test + fun `legacy earth grid endpoint is not restored`() { + mockMvc.perform(get("/api/earth-grid").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt new file mode 100644 index 0000000..c29db4d --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt @@ -0,0 +1,508 @@ +package org.nstart.dep265.requestservice.controller + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.isNull +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity +import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository +import org.nstart.dep265.requestservice.service.GeometryService +import org.nstart.dep265.requestservice.service.RequestGridProjectionService +import org.nstart.dep265.requestservice.service.RequestService +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.http.MediaType +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class RequestControllerOpenApiSkeletonTest { + private val requestRepository = mock(RequestRepository::class.java).also { repository -> + doReturn(Optional.empty()) + .`when`(repository) + .findById(any()) + doReturn(PageImpl(emptyList(), PageRequest.of(0, 50), 0)) + .`when`(repository) + .findRequests( + anyBoolean(), + deletedStatusMatcher(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + pageableMatcher(), + ) + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(repository) + .save(any(RequestEntity::class.java)) + } + private val requestOpticsParamsRepository = mock(RequestOpticsParamsRepository::class.java).also { repository -> + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(repository) + .save(any(RequestOpticsParamsEntity::class.java)) + doReturn(Optional.empty()) + .`when`(repository) + .findById(any()) + } + private val requestRsaParamsRepository = mock(RequestRsaParamsRepository::class.java).also { repository -> + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(repository) + .save(any(RequestRsaParamsEntity::class.java)) + doReturn(Optional.empty()) + .`when`(repository) + .findById(any()) + } + private val requestCellRepository = mock(RequestCellRepository::class.java).also { repository -> + doReturn(emptyList()) + .`when`(repository) + .findWithCellByRequestId(uuidMatcher()) + } + private val entityManager = mock(EntityManager::class.java) + + private val mockMvc = MockMvcBuilders + .standaloneSetup( + RequestController( + RequestService( + requestRepository = requestRepository, + requestOpticsParamsRepository = requestOpticsParamsRepository, + requestRsaParamsRepository = requestRsaParamsRepository, + requestCellRepository = requestCellRepository, + geometryService = GeometryService(), + requestGridProjectionService = mock(RequestGridProjectionService::class.java), + entityManager = entityManager, + ) + ) + ) + .setControllerAdvice(RequestApiExceptionHandler()) + .setMessageConverters( + MappingJackson2HttpMessageConverter( + jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS), + ), + ) + .build() + + @Test + fun `post v1 requests returns create response`() { + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "kpp": [1, 2, 3], + "highPriorityTransmit": false, + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ), + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.id").value("3fa85f64-5717-4562-b3fc-2c963f66afa6")) + .andExpect(jsonPath("$.name").value("Тест1")) + .andExpect(jsonPath("$.status").value("ACCEPTED")) + .andExpect(jsonPath("$.surveyType").value("OPTICS")) + .andExpect(jsonPath("$.importance").value(10.0)) + } + + @Test + fun `post v1 requests returns conflict when request id already exists`() { + val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + doReturn(true) + .`when`(requestRepository) + .existsById(id) + + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "$id", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ), + ) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_EXISTS")) + } + + @Test + fun `post v1 requests rejects missing name`() { + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `post v1 requests rejects blank name`() { + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": " ", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `post v1 requests rejects missing importance`() { + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `post v1 requests rejects negative importance`() { + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": -1.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `post v1 requests rejects missing optics and rsa`() { + mockMvc.perform( + post("/v1/requests") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z" + } + """.trimIndent(), + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `get v1 requests returns list response`() { + mockMvc.perform( + get("/v1/requests") + .queryParam("page", "2") + .queryParam("size", "25") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items").isArray) + .andExpect(jsonPath("$.page").value(2)) + .andExpect(jsonPath("$.size").value(25)) + .andExpect(jsonPath("$.totalItems").value(0)) + .andExpect(jsonPath("$.totalPages").value(0)) + } + + @Test + fun `get v1 requests returns importance in summary`() { + doReturn(PageImpl(listOf(existingRequest()))) + .`when`(requestRepository) + .findRequests( + anyBoolean(), + deletedStatusMatcher(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + pageableMatcher(), + ) + + mockMvc.perform(get("/v1/requests").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items[0].name").value("Тест1")) + .andExpect(jsonPath("$.items[0].importance").value(10.0)) + } + + @Test + fun `get v1 requests rejects unsupported sort field`() { + mockMvc.perform( + get("/v1/requests") + .queryParam("sort", "geometry,asc") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + } + + @Test + fun `get v1 requests by id returns importance`() { + val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + doReturn(Optional.of(existingRequest())) + .`when`(requestRepository) + .findById(id) + + mockMvc.perform(get("/v1/requests/$id").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.name").value("Тест1")) + .andExpect(jsonPath("$.importance").value(10.0)) + } + + @Test + fun `get v1 request with cells returns request and cell projection fields`() { + val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + val earthCell = EarthCellEntity( + cellId = 1, + cellNum = 32400, + latitude = 55.0, + longitude = 37.0, + importance = 5.0, + contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ) + doReturn(Optional.of(existingRequest())) + .`when`(requestRepository) + .findById(id) + doReturn( + listOf( + RequestCellEntity( + requestId = id, + coveragePercent = 25.0, + importance = 7.0, + contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", + cell = earthCell, + ) + ) + ) + .`when`(requestCellRepository) + .findWithCellByRequestId(id) + + mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.request.id").value(id.toString())) + .andExpect(jsonPath("$.request.name").value("Тест1")) + .andExpect(jsonPath("$.request.importance").value(10.0)) + .andExpect(jsonPath("$.request.geometry").value("POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))")) + .andExpect(jsonPath("$.request.coverage.currentPercent").value(50.0)) + .andExpect(jsonPath("$.cells[0].cellNum").value(32400)) + .andExpect(jsonPath("$.cells[0].coveragePercent").value(25.0)) + .andExpect(jsonPath("$.cells[0].importance").value(7.0)) + .andExpect(jsonPath("$.cells[0].contour").value("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))")) + } + + @Test + fun `get v1 request with cells returns empty cells for request without projections`() { + val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + doReturn(Optional.of(existingRequest())) + .`when`(requestRepository) + .findById(id) + doReturn(emptyList()) + .`when`(requestCellRepository) + .findWithCellByRequestId(id) + + mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.request.id").value(id.toString())) + .andExpect(jsonPath("$.cells").isEmpty) + } + + @Test + fun `get v1 request with cells returns deleted request with empty cells`() { + val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + val request = existingRequest().also { existingRequest -> + existingRequest.status = RequestStatus.DELETED + existingRequest.deletedAt = LocalDateTime.parse("2026-02-01T00:00:00") + } + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findById(id) + doReturn(emptyList()) + .`when`(requestCellRepository) + .findWithCellByRequestId(id) + + mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.request.status").value("DELETED")) + .andExpect(jsonPath("$.request.deletedAt").exists()) + .andExpect(jsonPath("$.cells").isEmpty) + } + + @Test + fun `get and delete by id return not found when request is absent`() { + val id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + mockMvc.perform(get("/v1/requests/$id").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound) + + mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound) + + mockMvc.perform(delete("/v1/requests/$id").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound) + } + + @Test + fun `legacy request with cells endpoint is not restored`() { + mockMvc.perform( + get("/api/requests/by-number/3fa85f64-5717-4562-b3fc-2c963f66afa6/with-cells") + .accept(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isNotFound) + } + + private fun existingRequest(): RequestEntity { + val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))" + return RequestEntity( + id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 0.5, + coverageRequiredPercent = 100.0, + importance = 10.0, + beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"), + endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"), + highPriorityTransmit = false, + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.ACTIVE, + createdAt = LocalDateTime.parse("2025-12-31T00:00:00"), + updatedAt = LocalDateTime.parse("2025-12-31T00:00:00"), + ) + } + + private fun deletedStatusMatcher(): RequestStatus { + any(RequestStatus::class.java) + return RequestStatus.DELETED + } + + private fun pageableMatcher(): Pageable { + any(Pageable::class.java) + return PageRequest.of(0, 50) + } + + private fun uuidMatcher(): UUID { + any(UUID::class.java) + return UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt new file mode 100644 index 0000000..85984fe --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt @@ -0,0 +1,126 @@ +package org.nstart.dep265.requestservice.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import java.time.OffsetDateTime +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class RequestApiDtoContractTest { + private val objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `create request dto matches openapi input fields`() { + val request = objectMapper.readValue( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "kpp": [1, 2, 3], + "highPriorityTransmit": false, + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0, + "sunAngleMin": 10.0, + "sunAngleMax": 45.0, + "clouds": 90.0 + } + } + """.trimIndent(), + ) + + assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id) + assertEquals("Тест1", request.name) + assertEquals(10.0, request.importance) + assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime) + assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType) + } + + @Test + fun `create request dto rejects survey type as input field`() { + val payloadWithSurveyType = """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T23:59:59Z", + "surveyType": "OPTICS", + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent() + + assertFailsWith { + objectMapper.readValue(payloadWithSurveyType) + } + } + + @Test + fun `create request serialization does not expose survey type`() { + val json = objectMapper.writeValueAsString( + CreateRequestRequestDto( + id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), + name = "Тест1", + geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + importance = 10.0, + beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), + endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + optics = OpticsParamsDto( + resultType = OpticsResultTypeDto.PANCHROMATIC, + resolution = 1.0, + ), + ), + ) + + assertFalse(json.contains("surveyType")) + assertFalse(json.contains("app_type")) + assertFalse(json.contains("appType")) + assertFalse(json.contains("AppType")) + assertFalse(json.contains("requestType")) + } + + @Test + fun `cells list response dto matches openapi fields`() { + val json = objectMapper.writeValueAsString( + CellsListResponseDto( + items = listOf( + CellSummaryResponseDto( + cellNum = 42, + latitude = 55.0, + longitude = 37.0, + importance = 10.0, + contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ) + ), + page = 0, + size = 50, + totalItems = 1, + totalPages = 1, + ) + ) + + assertEquals( + setOf("items", "page", "size", "totalItems", "totalPages"), + objectMapper.readTree(json).fieldNames().asSequence().toSet(), + ) + assertEquals( + setOf("cellNum", "latitude", "longitude", "importance", "contour"), + objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(), + ) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/mapper/RoutePassportMapperTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/mapper/RoutePassportMapperTest.kt new file mode 100644 index 0000000..eeb4a17 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/mapper/RoutePassportMapperTest.kt @@ -0,0 +1,46 @@ +package org.nstart.dep265.requestservice.mapper + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +class RoutePassportMapperTest { + private val mapper = RoutePassportMapper() + + @Test + fun `should convert route roll angle to absolute range`() { + val routePassportDto = RoutePassportDto( + routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"), + kaShort = "S1A", + kaFull = "sentinel-1a", + routeNameFull = "ROUTE-001", + routeNameShort = "S1A-GRDH", + orbitNumber = 59796L, + orbitState = "Descending", + intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0), + intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30), + rollAngle = AngleRangeDto( + min = BigDecimal("-5.0"), + max = BigDecimal("12.0"), + ), + visirAngle = AngleRangeDto( + min = BigDecimal("21.0"), + max = BigDecimal("24.5"), + ), + resolutionRange = BigDecimal("10.0"), + resolutionAzimuth = BigDecimal("10.0"), + polarisation = listOf("VH", "VV"), + processingLevel = "GRD", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))", + ) + + val route = mapper.toRouteDto(routePassportDto) + + assertEquals(0.0, route.rollAngle.min) + assertEquals(12.0, route.rollAngle.max) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt new file mode 100644 index 0000000..7cf9b2c --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt @@ -0,0 +1,109 @@ +package org.nstart.dep265.requestservice.repository + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.nstart.dep265.requestservice.PcpRequestServiceApplication +import org.nstart.dep265.requestservice.entity.OutboxEventEntity +import org.nstart.dep265.requestservice.entity.OutboxEventStatus +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +@SpringBootTest( + classes = [PcpRequestServiceApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = [ + "spring.config.import=", + "spring.cloud.config.enabled=false", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:outbox-events;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.kafka.listener.auto-startup=false", + "spring.kafka.consumer.group-id=request-service-test", + "app.kafka.topics.route=request-service-test-route", + ], +) +class OutboxEventRepositoryJpaTest @Autowired constructor( + private val outboxEventRepository: OutboxEventRepository, +) { + private val objectMapper = jacksonObjectMapper() + + @BeforeEach + fun setUp() { + outboxEventRepository.deleteAll() + } + + @Test + fun `unique constraint prevents duplicate request completed event for same request`() { + val requestId = UUID.fromString("73da563f-448b-4303-a1df-41d618d3cb6d") + outboxEventRepository.saveAndFlush(outboxEvent(requestId = requestId)) + + assertFailsWith { + outboxEventRepository.saveAndFlush(outboxEvent(requestId = requestId)) + } + } + + @Test + @Transactional + fun `publishable query returns new events before failed and excludes published events`() { + val oldFailedEvent = outboxEvent( + requestId = UUID.fromString("58c9fb48-3930-4c5d-8486-4385130c4706"), + createdAt = LocalDateTime.parse("2026-01-01T09:00:00"), + status = OutboxEventStatus.FAILED, + ) + val newEvent = outboxEvent( + requestId = UUID.fromString("85cb5fb8-9666-4705-bf02-1376a5d74975"), + createdAt = LocalDateTime.parse("2026-01-01T10:00:00"), + status = OutboxEventStatus.NEW, + ) + val newerFailedEvent = outboxEvent( + requestId = UUID.fromString("62faf96d-8bd5-46ba-a541-c10e78851f86"), + createdAt = LocalDateTime.parse("2026-01-01T10:01:00"), + status = OutboxEventStatus.FAILED, + ) + val publishedEvent = outboxEvent( + requestId = UUID.fromString("75051f40-9e67-4f72-9279-b4c64da409d9"), + status = OutboxEventStatus.PUBLISHED, + ) + val otherEventType = outboxEvent( + requestId = UUID.fromString("d911b183-2ba5-493f-81fa-944366db63b5"), + eventType = "REQUEST_CREATED", + status = OutboxEventStatus.NEW, + ) + outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType)) + + val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( + eventType = "REQUEST_COMPLETED", + batchSize = 10, + ) + + assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id }) + } + + private fun outboxEvent( + requestId: UUID, + eventType: String = "REQUEST_COMPLETED", + status: OutboxEventStatus = OutboxEventStatus.NEW, + createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), + ): OutboxEventEntity { + return OutboxEventEntity( + id = UUID.randomUUID(), + eventType = eventType, + aggregateType = "REQUEST", + aggregateId = requestId, + payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""), + status = status, + createdAt = createdAt, + ) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/CellsQueryServiceJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/CellsQueryServiceJpaTest.kt new file mode 100644 index 0000000..07faf2c --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/CellsQueryServiceJpaTest.kt @@ -0,0 +1,293 @@ +package org.nstart.dep265.requestservice.service + +import org.junit.jupiter.api.Test +import org.nstart.dep265.requestservice.PcpRequestServiceApplication +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.transaction.annotation.Transactional +import java.util.UUID +import jakarta.persistence.EntityManager +import kotlin.test.assertEquals + +@SpringBootTest( + classes = [PcpRequestServiceApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = [ + "spring.config.import=", + "spring.cloud.config.enabled=false", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:cells-query;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.kafka.listener.auto-startup=false", + "spring.kafka.consumer.group-id=request-service-test", + "app.kafka.topics.route=request-service-test-route", + ], +) +@Transactional +class CellsQueryServiceJpaTest @Autowired constructor( + private val earthCellRepository: EarthCellRepository, + private val requestCellRepository: RequestCellRepository, + private val entityManager: EntityManager, + private val service: CellsQueryService, +) { + @Test + fun `list cells without min importance returns all cells paged`() { + saveCell(cellNum = 1, importance = 3.0) + saveCell(cellNum = 2, importance = 2.0) + saveCell(cellNum = 3, importance = 1.0) + + val response = service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 1, + size = 2, + sort = null, + ) + + assertEquals(listOf(3L), response.items.map { cell -> cell.cellNum }) + assertEquals(1, response.page) + assertEquals(2, response.size) + assertEquals(3, response.totalItems) + assertEquals(2, response.totalPages) + } + + @Test + fun `list cells filters by min importance in repository query`() { + saveCell(cellNum = 1, importance = 3.0) + saveCell(cellNum = 2, importance = 1.0) + saveCell(cellNum = 3, importance = 2.0) + + val response = service.listCells( + minImportance = 2.0, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + assertEquals(listOf(1L), response.items.map { cell -> cell.cellNum }) + assertEquals(1, response.totalItems) + } + + @Test + fun `list cells uses strict min importance filter and counts only matching cells`() { + saveCell(cellNum = 1, importance = 0.0) + saveCell(cellNum = 2, importance = 0.5) + + val response = service.listCells( + minImportance = 0.0, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + assertEquals(listOf(2L), response.items.map { cell -> cell.cellNum }) + assertEquals(1, response.totalItems) + assertEquals(1, response.totalPages) + } + + @Test + fun `list cells excludes cells equal to legacy min importance threshold`() { + saveCell(cellNum = 1, importance = 0.001) + saveCell(cellNum = 2, importance = 0.002) + + val response = service.listCells( + minImportance = 0.001, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + assertEquals(listOf(2L), response.items.map { cell -> cell.cellNum }) + assertEquals(1, response.totalItems) + } + + @Test + fun `list cells without min importance includes zero importance cells`() { + saveCell(cellNum = 1, importance = 0.0) + saveCell(cellNum = 2, importance = 0.5) + + val response = service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + assertEquals(listOf(2L, 1L), response.items.map { cell -> cell.cellNum }) + assertEquals(2, response.totalItems) + } + + @Test + fun `list cells uses default importance desc and cell num asc sort`() { + saveCell(cellNum = 3, importance = 5.0) + saveCell(cellNum = 1, importance = 5.0) + saveCell(cellNum = 2, importance = 7.0) + + val response = service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + assertEquals(listOf(2L, 1L, 3L), response.items.map { cell -> cell.cellNum }) + } + + @Test + fun `list cells supports whitelisted sort field`() { + saveCell(cellNum = 1, latitude = 55.0) + saveCell(cellNum = 2, latitude = 53.0) + + val response = service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = "latitude,asc", + ) + + assertEquals(listOf(2L, 1L), response.items.map { cell -> cell.cellNum }) + } + + @Test + fun `list cells returns response fields matching openapi schema`() { + saveCell( + cellNum = 42, + latitude = 55.0, + longitude = 37.0, + importance = 10.0, + contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ) + + val response = service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + val cell = response.items.single() + assertEquals(42L, cell.cellNum) + assertEquals(55.0, cell.latitude) + assertEquals(37.0, cell.longitude) + assertEquals(10.0, cell.importance) + assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", cell.contour) + } + + @Test + fun `list cells with requests returns all cells and request fragment fields`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000201") + val cellWithRequest = saveCell(cellNum = 10, importance = 5.0) + saveCell(cellNum = 11, importance = 0.0) + requestCellRepository.saveAndFlush( + RequestCellEntity( + requestId = requestId, + coveragePercent = 33.0, + importance = 7.0, + contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", + cell = cellWithRequest, + ) + ) + entityManager.clear() + + val response = service.listCellsWithRequests( + minImportance = null, + countLat = null, + countLong = null, + ) + + assertEquals(listOf(10L, 11L), response.items.map { cell -> cell.cellNum }) + assertEquals(emptyList(), response.items[1].requests) + + val fragment = response.items.first().requests.single() + assertEquals(requestId, fragment.requestId) + assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", fragment.contour) + assertEquals(33.0, fragment.coveragePercent) + assertEquals(7.0, fragment.importance) + } + + @Test + fun `list cells with requests filters by earth cell importance`() { + saveCell(cellNum = 10, importance = 5.0) + saveCell(cellNum = 11, importance = 2.0) + entityManager.clear() + + val response = service.listCellsWithRequests( + minImportance = 2.0, + countLat = null, + countLong = null, + ) + + assertEquals(listOf(10L), response.items.map { cell -> cell.cellNum }) + } + + @Test + fun `list cells with requests uses strict min importance filter`() { + saveCell(cellNum = 10, importance = 0.0) + saveCell(cellNum = 11, importance = 1.0) + entityManager.clear() + + val response = service.listCellsWithRequests( + minImportance = 0.0, + countLat = null, + countLong = null, + ) + + assertEquals(listOf(11L), response.items.map { cell -> cell.cellNum }) + } + + @Test + fun `list cells with requests excludes cells equal to legacy min importance threshold`() { + saveCell(cellNum = 10, importance = 0.001) + saveCell(cellNum = 11, importance = 0.002) + entityManager.clear() + + val response = service.listCellsWithRequests( + minImportance = 0.001, + countLat = null, + countLong = null, + ) + + assertEquals(listOf(11L), response.items.map { cell -> cell.cellNum }) + } + + private fun saveCell( + cellNum: Long, + latitude: Double = 55.0, + longitude: Double = 37.0, + importance: Double = 0.0, + contour: String = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ): EarthCellEntity { + return earthCellRepository.saveAndFlush( + EarthCellEntity( + cellNum = cellNum, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = contour, + ) + ) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/CellsQueryServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/CellsQueryServiceTest.kt new file mode 100644 index 0000000..3d4637d --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/CellsQueryServiceTest.kt @@ -0,0 +1,258 @@ +package org.nstart.dep265.requestservice.service + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.isNull +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import java.util.Optional +import java.util.UUID +import kotlin.test.assertEquals + +class CellsQueryServiceTest { + @Test + fun `list cells uses repository pageable query and does not call find all`() { + val earthCellRepository = emptyCellsRepository() + val service = service(earthCellRepository) + + service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + verify(earthCellRepository).findCells(isNull(), pageableMatcher()) + verify(earthCellRepository, never()).findAll() + } + + @Test + fun `list cells builds default sort`() { + val capturedPageables = mutableListOf() + val earthCellRepository = emptyCellsRepository(capturedPageables) + val service = service(earthCellRepository) + + service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + + val sort = capturedPageables.single().sort.toList() + assertEquals("importance", sort[0].property) + assertEquals(false, sort[0].isAscending) + assertEquals("cellNum", sort[1].property) + assertEquals(true, sort[1].isAscending) + } + + @Test + fun `list cells rejects invalid query values`() { + val service = service(emptyCellsRepository()) + + assertThrows { + service.listCells( + minImportance = -1.0, + countLat = null, + countLong = null, + page = 0, + size = 50, + sort = null, + ) + } + + assertThrows { + service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = -1, + size = 50, + sort = null, + ) + } + + assertThrows { + service.listCells( + minImportance = null, + countLat = null, + countLong = null, + page = 0, + size = 501, + sort = null, + ) + } + } + + @Test + fun `list cells with requests returns fragments from request cells`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000101") + val cellWithRequest = cell(cellNum = 10, importance = 5.0) + cellWithRequest.requestProjections = mutableListOf( + RequestCellEntity( + requestId = requestId, + contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", + coveragePercent = 25.0, + importance = 7.0, + cell = cellWithRequest, + ) + ) + val cellWithoutRequests = cell(cellNum = 11, importance = 0.0) + val earthCellRepository = emptyCellsRepository().also { repository -> + doReturn(listOf(cellWithRequest, cellWithoutRequests)) + .`when`(repository) + .findCellsWithRequestProjections(isNull()) + } + val service = service(earthCellRepository) + + val response = service.listCellsWithRequests( + minImportance = null, + countLat = null, + countLong = null, + ) + + assertEquals(listOf(10L, 11L), response.items.map { cell -> cell.cellNum }) + assertEquals(emptyList(), response.items[1].requests) + + val fragment = response.items.first().requests.single() + assertEquals(requestId, fragment.requestId) + assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", fragment.contour) + assertEquals(25.0, fragment.coveragePercent) + assertEquals(7.0, fragment.importance) + verify(earthCellRepository).findCellsWithRequestProjections(isNull()) + verify(earthCellRepository, never()).findCells(isNull(), pageableMatcher()) + verify(earthCellRepository, never()).findAll() + } + + @Test + fun `list cells with requests passes min importance to repository`() { + val earthCellRepository = emptyCellsRepository().also { repository -> + doReturn(listOf(cell(cellNum = 10, importance = 5.0))) + .`when`(repository) + .findCellsWithRequestProjections(2.0) + } + val service = service(earthCellRepository) + + val response = service.listCellsWithRequests( + minImportance = 2.0, + countLat = null, + countLong = null, + ) + + assertEquals(listOf(10L), response.items.map { cell -> cell.cellNum }) + verify(earthCellRepository).findCellsWithRequestProjections(2.0) + } + + @Test + fun `list cells with requests rejects partial aggregation parameters`() { + val service = service(emptyCellsRepository()) + + assertThrows { + service.listCellsWithRequests( + minImportance = null, + countLat = 2, + countLong = null, + ) + } + + assertThrows { + service.listCellsWithRequests( + minImportance = null, + countLat = null, + countLong = 2, + ) + } + } + + @Test + fun `list cells with requests delegates to existing aggregation when both counts are present`() { + val earthCellRepository = emptyCellsRepository().also { repository -> + doReturn( + listOf( + cell(cellNum = 10, latitude = -88.0, longitude = 0.0, importance = 1.0), + cell(cellNum = 11, latitude = -88.0, longitude = 2.0, importance = 2.0), + ) + ) + .`when`(repository) + .findCellsWithRequestProjections(isNull()) + } + val service = service(earthCellRepository) + + val response = service.listCellsWithRequests( + minImportance = null, + countLat = 1, + countLong = 2, + ) + + assertEquals(1, response.items.size) + assertEquals(10L, response.items.first().cellNum) + assertEquals(3.0, response.items.first().importance) + assertEquals("POLYGON ((-2.0 -90.0, 2.0 -90.0, 2.0 -88.0, -2.0 -88.0, -2.0 -90.0))", response.items.first().contour) + } + + private fun emptyCellsRepository(capturedPageables: MutableList = mutableListOf()): EarthCellRepository { + return mock(EarthCellRepository::class.java).also { repository -> + doAnswer { invocation -> + capturedPageables += invocation.getArgument(1) + PageImpl(emptyList(), PageRequest.of(0, 50), 0) + } + .`when`(repository) + .findCells(isNull(), pageableMatcher()) + doReturn(emptyList()) + .`when`(repository) + .findCellsWithRequestProjections(isNull()) + } + } + + private fun service(earthCellRepository: EarthCellRepository): CellsQueryService { + val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository -> + doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + .`when`(repository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + } + val earthGridCatalogService = EarthGridCatalogService( + earthCellRepository = earthCellRepository, + earthGridSettingsRepository = earthGridSettingsRepository, + compatGeometryService = CompatGeometryService(GeometryService()), + gridEnabled = false, + ) + return CellsQueryService(earthCellRepository, earthGridCatalogService) + } + + private fun cell( + cellNum: Long, + latitude: Double = 55.0, + longitude: Double = 37.0, + importance: Double = 0.0, + contour: String = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ): EarthCellEntity { + return EarthCellEntity( + cellNum = cellNum, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = contour, + ) + } + + private fun pageableMatcher(): Pageable { + any(Pageable::class.java) + return PageRequest.of(0, 50) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/EarthGridCatalogServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/EarthGridCatalogServiceTest.kt new file mode 100644 index 0000000..4f7d526 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/EarthGridCatalogServiceTest.kt @@ -0,0 +1,177 @@ +package org.nstart.dep265.requestservice.service + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyList +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import java.util.Optional +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EarthGridCatalogServiceTest { + + private val earthCellRepository = mock(EarthCellRepository::class.java) + private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java) + private val geometryService = GeometryService() + private val compatGeometryService = CompatGeometryService(geometryService) + + @Test + fun `init stores requested calculation step`() { + val currentSettings = EarthGridSettingsEntity(calculationStep = 2.0) + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(currentSettings)) + val service = service(gridEnabled = false) + + service.init(3.5) + + val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java) + verify(earthGridSettingsRepository).save(settingsCaptor.capture()) + verifyNoInteractions(earthCellRepository) + assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId) + assertEquals(3.5, settingsCaptor.value.calculationStep) + } + + @Test + fun `init creates settings row when it is missing`() { + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.empty()) + val service = service(gridEnabled = false) + + service.init(1.0) + + val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java) + verify(earthGridSettingsRepository).save(settingsCaptor.capture()) + assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId) + assertEquals(1.0, settingsCaptor.value.calculationStep) + } + + @Test + fun `init creates earth cells with zero non-null importance`() { + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + val savedCells = mutableListOf() + org.mockito.Mockito.doAnswer { invocation -> + val cells = invocation.getArgument>(0) + savedCells += cells + cells + }.`when`(earthCellRepository).saveAll(anyList()) + val service = service(gridEnabled = true) + + service.init(180.0) + + assertTrue(savedCells.isNotEmpty()) + assertTrue(savedCells.all { earthCell -> earthCell.importance == 0.0 }) + } + + @Test + fun `by importance returns source cells when aggregation params are absent`() { + `when`(earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(0.0)) + .thenReturn(listOf(cell(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 2.0))) + val service = service(gridEnabled = false) + + val cells = service.byImportance(0.0) + + assertEquals(1, cells.size) + assertEquals(10L, cells.first().num) + assertEquals(2.0, cells.first().importance) + } + + @Test + fun `by importance aggregates neighbor cells using stored calculation step`() { + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + `when`(earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(0.0)) + .thenReturn( + listOf( + cell(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 1.0), + cell(id = 2L, num = 11L, latitude = -88.0, longitude = 2.0, importance = 2.0), + cell(id = 3L, num = 12L, latitude = -86.0, longitude = 0.0, importance = 3.0), + cell(id = 4L, num = 13L, latitude = -86.0, longitude = 2.0, importance = 4.0), + ) + ) + val service = service(gridEnabled = false) + + val cells = service.byImportance(0.0, countLat = 2, countLong = 2) + + assertEquals(1, cells.size) + assertEquals(10.0, cells.first().importance) + assertEquals(-88.0, cells.first().latitude) + assertEquals(0.0, cells.first().longitude) + assertEquals( + "POLYGON ((-2.0 -90.0, 2.0 -90.0, 2.0 -86.0, -2.0 -86.0, -2.0 -90.0))", + cells.first().contour, + ) + } + + @Test + fun `with requests aggregates cells and keeps request fragments`() { + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + `when`(earthCellRepository.findCellsWithRequestProjections(null)) + .thenReturn( + listOf( + cellWithRequest(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 1.0), + cellWithRequest(id = 2L, num = 11L, latitude = -88.0, longitude = 2.0, importance = 2.0), + ) + ) + val service = service(gridEnabled = false) + + val cells = service.allWithRequests(countLat = 1, countLong = 2) + + assertEquals(1, cells.size) + assertEquals(3.0, cells.first().importance) + assertEquals(2, cells.first().requests.count()) + } + + private fun service(gridEnabled: Boolean) = + EarthGridCatalogService( + earthCellRepository = earthCellRepository, + earthGridSettingsRepository = earthGridSettingsRepository, + compatGeometryService = compatGeometryService, + gridEnabled = gridEnabled, + ) + + private fun cell( + id: Long, + num: Long, + latitude: Double, + longitude: Double, + importance: Double, + ) = EarthCellEntity( + cellId = id, + cellNum = num, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = "", + ) + + private fun cellWithRequest( + id: Long, + num: Long, + latitude: Double, + longitude: Double, + importance: Double, + ): EarthCellEntity { + val earthCell = cell(id, num, latitude, longitude, importance) + earthCell.requestProjections = mutableListOf( + RequestCellEntity( + id = id, + requestId = UUID.fromString("00000000-0000-0000-0000-${id.toString().padStart(12, '0')}"), + contour = "POLYGON EMPTY", + importance = importance, + cell = earthCell, + ) + ) + return earthCell + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/GridRebuildServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/GridRebuildServiceTest.kt new file mode 100644 index 0000000..08d88b4 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/GridRebuildServiceTest.kt @@ -0,0 +1,162 @@ +package org.nstart.dep265.requestservice.service + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyCollection +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.inOrder +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class GridRebuildServiceTest { + private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository -> + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(repository) + .save(org.mockito.ArgumentMatchers.any(EarthGridSettingsEntity::class.java)) + } + private val gridSettingsService = GridSettingsService(earthGridSettingsRepository) + private val earthGridCatalogService = mock(EarthGridCatalogService::class.java) + private val requestCellRepository = mock(RequestCellRepository::class.java) + private val requestRepository = mock(RequestRepository::class.java) + private val requestGridProjectionService = mock(RequestGridProjectionService::class.java) + private val earthCellRepository = mock(EarthCellRepository::class.java) + private val gridRebuildLockService = mock(GridRebuildLockService::class.java) + + @Test + fun `rebuild grid rebuilds catalog and projections only for active requests`() { + val acceptedRequest = request(status = RequestStatus.ACCEPTED) + val activeRequest = request(status = RequestStatus.ACTIVE) + val completedRequest = request(status = RequestStatus.COMPLETED) + val expiredRequest = request(status = RequestStatus.EXPIRED) + val deletedRequest = request(status = RequestStatus.DELETED) + + doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock() + doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 180.0))) + .`when`(earthGridSettingsRepository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + doReturn(listOf(acceptedRequest, activeRequest)) + .`when`(requestRepository) + .findByStatusIn(anyCollection()) + doReturn(2L).`when`(earthCellRepository).count() + + val response = service().rebuildGrid() + + assertEquals(2L, response.cellsCount) + assertEquals(2L, response.rebuiltRequestProjectionsCount) + + val order = inOrder(requestCellRepository, earthGridCatalogService, requestGridProjectionService) + order.verify(requestCellRepository).deleteAllInBatch() + order.verify(earthGridCatalogService).init(180.0) + order.verify(requestGridProjectionService).rebuildProjection(acceptedRequest) + order.verify(requestGridProjectionService).rebuildProjection(activeRequest) + + verify(requestRepository).findByStatusIn(setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE)) + verify(requestGridProjectionService, never()).rebuildProjection(completedRequest) + verify(requestGridProjectionService, never()).rebuildProjection(expiredRequest) + verify(requestGridProjectionService, never()).rebuildProjection(deletedRequest) + } + + @Test + fun `rebuild grid uses default settings when settings row is missing`() { + doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock() + doReturn(Optional.empty()) + .`when`(earthGridSettingsRepository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + doReturn(emptyList()) + .`when`(requestRepository) + .findByStatusIn(anyCollection()) + doReturn(0L).`when`(earthCellRepository).count() + + val response = service().rebuildGrid() + + assertEquals(0L, response.rebuiltRequestProjectionsCount) + verify(earthGridCatalogService).init(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP) + } + + @Test + fun `rebuild grid fails with conflict before side effects when lock is busy`() { + doReturn(false).`when`(gridRebuildLockService).tryAcquireTransactionLock() + + assertFailsWith { + service().rebuildGrid() + } + + verifyNoInteractions( + earthGridSettingsRepository, + earthGridCatalogService, + requestCellRepository, + requestRepository, + requestGridProjectionService, + earthCellRepository, + ) + } + + @Test + fun `rebuild grid does not mutate request domain fields`() { + val activeRequest = request(status = RequestStatus.ACTIVE) + val remainingGeometry = activeRequest.remainingGeometry + val remainingArea = activeRequest.remainingArea + + doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock() + doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 180.0))) + .`when`(earthGridSettingsRepository) + .findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID) + doReturn(listOf(activeRequest)) + .`when`(requestRepository) + .findByStatusIn(anyCollection()) + doReturn(2L).`when`(earthCellRepository).count() + + service().rebuildGrid() + + assertEquals(remainingGeometry, activeRequest.remainingGeometry) + assertEquals(remainingArea, activeRequest.remainingArea) + assertEquals(RequestStatus.ACTIVE, activeRequest.status) + } + + private fun service() = + GridRebuildService( + gridSettingsService = gridSettingsService, + earthGridCatalogService = earthGridCatalogService, + requestCellRepository = requestCellRepository, + requestRepository = requestRepository, + requestGridProjectionService = requestGridProjectionService, + earthCellRepository = earthCellRepository, + gridRebuildLockService = gridRebuildLockService, + ) + + private fun request(status: RequestStatus): RequestEntity { + val geometry = "POLYGON ((0 1, 1 1, 1 0, 0 0, 0 1))" + val now = LocalDateTime.parse("2026-05-20T10:00:00") + return RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 0.5, + importance = 7.0, + beginDateTime = now, + endDateTime = now.plusDays(1), + surveyType = RequestSurveyType.OPTICS, + status = status, + createdAt = now, + updatedAt = now, + deletedAt = if (status == RequestStatus.DELETED) now else null, + ) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/GridSettingsServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/GridSettingsServiceTest.kt new file mode 100644 index 0000000..ed03bf4 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/GridSettingsServiceTest.kt @@ -0,0 +1,98 @@ +package org.nstart.dep265.requestservice.service + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto +import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import java.util.Optional +import kotlin.test.assertEquals + +class GridSettingsServiceTest { + private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository -> + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(repository) + .save(any(EarthGridSettingsEntity::class.java)) + } + + @Test + fun `get settings returns current settings`() { + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 4.0))) + val service = GridSettingsService(earthGridSettingsRepository) + + val response = service.getSettings() + + assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, response.settingsId) + assertEquals(4.0, response.calculationStep) + verify(earthGridSettingsRepository, never()).save(any(EarthGridSettingsEntity::class.java)) + } + + @Test + fun `get settings creates and returns default settings when missing`() { + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.empty()) + val service = GridSettingsService(earthGridSettingsRepository) + + val response = service.getSettings() + + val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java) + verify(earthGridSettingsRepository).save(settingsCaptor.capture()) + assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId) + assertEquals(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, settingsCaptor.value.calculationStep) + assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, response.settingsId) + assertEquals(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, response.calculationStep) + } + + @Test + fun `put settings updates calculation step`() { + val currentSettings = EarthGridSettingsEntity(calculationStep = 2.0) + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(currentSettings)) + val service = GridSettingsService(earthGridSettingsRepository) + + val response = service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 3.5)) + + val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java) + verify(earthGridSettingsRepository).save(settingsCaptor.capture()) + assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId) + assertEquals(3.5, settingsCaptor.value.calculationStep) + assertEquals(3.5, response.calculationStep) + } + + @Test + fun `put settings does not rebuild earth cells delete request cells or project requests`() { + val earthCellRepository = mock(EarthCellRepository::class.java) + val requestCellRepository = mock(RequestCellRepository::class.java) + val requestGridProjectionService = mock(RequestGridProjectionService::class.java) + `when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)) + .thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0))) + val service = GridSettingsService(earthGridSettingsRepository) + + service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 1.0)) + + verifyNoInteractions(earthCellRepository) + verifyNoInteractions(requestCellRepository) + verifyNoInteractions(requestGridProjectionService) + } + + @Test + fun `put settings rejects non-positive calculation step`() { + val service = GridSettingsService(earthGridSettingsRepository) + + assertThrows { + service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 0.0)) + } + verify(earthGridSettingsRepository, never()).save(any(EarthGridSettingsEntity::class.java)) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt new file mode 100644 index 0000000..83b327c --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt @@ -0,0 +1,282 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.nstart.dep265.requestservice.config.OutboxProperties +import org.nstart.dep265.requestservice.entity.OutboxEventEntity +import org.nstart.dep265.requestservice.entity.OutboxEventStatus +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.SendResult +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.CompletableFuture + +class OutboxPublisherServiceTest { + private val objectMapper = jacksonObjectMapper() + private val properties = OutboxProperties( + requestCompletedTopic = "pcp.request.completed.v1", + publishBatchSize = 50, + publishFixedDelayMs = 5_000, + ) + + @Test + fun `new request completed event is published and marked as published`() { + val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4") + val event = outboxEvent( + requestId = requestId, + payload = """{"requestId":"$requestId","status":"COMPLETED","source":"outbox"}""", + ) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(successfulSend()) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + val processedCount = service.publishPending() + + assertEquals(1, processedCount) + verify(kafkaTemplate).send( + properties.requestCompletedTopic, + requestId.toString(), + event.payload.toString(), + ) + assertEquals(OutboxEventStatus.PUBLISHED, event.status) + assertNotNull(event.publishedAt) + assertNull(event.errorMessage) + verify(outboxEventRepository).save(event) + } + + @Test + fun `failed request completed event is picked for retry`() { + val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f") + val event = outboxEvent( + requestId = requestId, + status = OutboxEventStatus.FAILED, + errorMessage = "previous failure", + ) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(successfulSend()) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + service.publishPending() + + verify(kafkaTemplate).send( + properties.requestCompletedTopic, + requestId.toString(), + event.payload.toString(), + ) + assertEquals(OutboxEventStatus.PUBLISHED, event.status) + assertNull(event.errorMessage) + } + + @Test + fun `payload from outbox event is sent without rebuilding it`() { + val requestId = UUID.fromString("e390c558-a09f-41c6-9e45-307c04bb3365") + val savedPayload = """{"requestId":"$requestId","status":"COMPLETED","customField":"kept-from-outbox"}""" + val event = outboxEvent(requestId = requestId, payload = savedPayload) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(successfulSend()) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + service.publishPending() + + verify(kafkaTemplate).send( + properties.requestCompletedTopic, + requestId.toString(), + objectMapper.readTree(savedPayload).toString(), + ) + } + + @Test + fun `kafka failure marks event as failed and stores short error message`() { + val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb") + val event = outboxEvent(requestId = requestId) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("broker unavailable"))) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + val processedCount = service.publishPending() + + assertEquals(1, processedCount) + assertEquals(OutboxEventStatus.FAILED, event.status) + assertTrue(event.errorMessage!!.contains("broker unavailable")) + assertNull(event.publishedAt) + verify(outboxEventRepository).save(event) + } + + @Test + fun `failed event is not retried repeatedly in same publish pending call`() { + val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7") + val event = outboxEvent( + requestId = requestId, + status = OutboxEventStatus.FAILED, + errorMessage = "previous failure", + ) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken"))) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + val processedCount = service.publishPending() + + assertEquals(1, processedCount) + verify(kafkaTemplate, times(1)).send( + properties.requestCompletedTopic, + requestId.toString(), + event.payload.toString(), + ) + verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked( + RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, + properties.publishBatchSize, + ) + assertEquals(OutboxEventStatus.FAILED, event.status) + assertTrue(event.errorMessage!!.contains("still broken")) + } + + @Test + fun `failed event does not block other events from same batch`() { + val failedRequestId = UUID.fromString("7000c57f-a41a-4788-8929-42a181ff2135") + val newRequestId = UUID.fromString("e5ca9291-4d59-457c-995f-91c618dbd872") + val failedEvent = outboxEvent( + requestId = failedRequestId, + status = OutboxEventStatus.FAILED, + errorMessage = "previous failure", + ) + val newEvent = outboxEvent(requestId = newRequestId) + val outboxEventRepository = repositoryReturning(listOf(newEvent, failedEvent)) + val kafkaTemplate = kafkaTemplateFailingForKeys(setOf(failedRequestId.toString())) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + val processedCount = service.publishPending() + + assertEquals(2, processedCount) + verify(kafkaTemplate).send( + properties.requestCompletedTopic, + failedRequestId.toString(), + failedEvent.payload.toString(), + ) + verify(kafkaTemplate).send( + properties.requestCompletedTopic, + newRequestId.toString(), + newEvent.payload.toString(), + ) + assertEquals(OutboxEventStatus.FAILED, failedEvent.status) + assertEquals(OutboxEventStatus.PUBLISHED, newEvent.status) + assertNotNull(newEvent.publishedAt) + } + + @Test + fun `published event is not published again when repository returns no publishable rows`() { + val outboxEventRepository = repositoryReturning(emptyList()) + val kafkaTemplate = kafkaTemplateReturning(successfulSend()) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + val processedCount = service.publishPending() + + assertEquals(0, processedCount) + verifyNoInteractions(kafkaTemplate) + } + + private fun publisherService( + outboxEventRepository: OutboxEventRepository, + kafkaTemplate: KafkaTemplate, + ): OutboxPublisherService { + return OutboxPublisherService( + outboxEventRepository = outboxEventRepository, + kafkaTemplate = kafkaTemplate, + outboxProperties = properties, + transactionOperations = ImmediateTransactionOperations(), + ) + } + + private fun repositoryReturning(events: List): OutboxEventRepository { + val outboxEventRepository = mock(OutboxEventRepository::class.java) + doReturn(events) + .`when`(outboxEventRepository) + .findRequestCompletedPublishableForUpdateSkipLocked( + RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, + properties.publishBatchSize, + ) + return outboxEventRepository + } + + @Suppress("UNCHECKED_CAST") + private fun kafkaTemplateReturning( + result: CompletableFuture>, + ): KafkaTemplate { + val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + doReturn(result) + .`when`(kafkaTemplate) + .send(anyString(), anyString(), anyString()) + return kafkaTemplate + } + + @Suppress("UNCHECKED_CAST") + private fun kafkaTemplateFailingForKeys(failedKeys: Set): KafkaTemplate { + val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + doAnswer { invocation -> + val key = invocation.arguments[1] as String + if (key in failedKeys) { + failedSend(IllegalStateException("broker unavailable for $key")) + } else { + successfulSend() + } + } + .`when`(kafkaTemplate) + .send(anyString(), anyString(), anyString()) + return kafkaTemplate + } + + @Suppress("UNCHECKED_CAST") + private fun successfulSend(): CompletableFuture> { + val sendResult = mock(SendResult::class.java) as SendResult + return CompletableFuture.completedFuture(sendResult) + } + + private fun failedSend(exception: Exception): CompletableFuture> { + val future = CompletableFuture>() + future.completeExceptionally(exception) + return future + } + + private fun outboxEvent( + requestId: UUID, + payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""", + status: OutboxEventStatus = OutboxEventStatus.NEW, + errorMessage: String? = null, + ): OutboxEventEntity { + return OutboxEventEntity( + id = UUID.randomUUID(), + eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, + aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE, + aggregateId = requestId, + payload = objectMapper.readTree(payload), + status = status, + createdAt = LocalDateTime.parse("2026-01-01T10:00:00"), + errorMessage = errorMessage, + ) + } + + private class ImmediateTransactionOperations : TransactionOperations { + override fun execute(action: TransactionCallback): T { + return action.doInTransaction(SimpleTransactionStatus()) + } + + override fun executeWithoutResult(action: java.util.function.Consumer) { + action.accept(SimpleTransactionStatus()) + } + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt new file mode 100644 index 0000000..944eea5 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt @@ -0,0 +1,65 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockingDetails +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import java.time.LocalDateTime +import java.util.UUID + +class RequestCompletedOutboxServiceTest { + private val objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules() + + @Test + fun `request completed payload contains required fields`() { + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = RequestCompletedOutboxService( + outboxEventRepository = outboxEventRepository, + objectMapper = objectMapper, + geometryService = GeometryService(), + ) + val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846") + val completedAt = LocalDateTime.parse("2026-01-01T10:15:30") + val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00") + val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00") + val request = RequestEntity( + id = requestId, + name = "Тест1", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + remainingGeometry = "POLYGON EMPTY", + geometryArea = 1.0, + remainingArea = 0.0, + importance = 10.0, + beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), + endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"), + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.COMPLETED, + lastMatchedAt = matchedAt, + completedAt = completedAt, + createdAt = LocalDateTime.parse("2026-01-01T09:00:00"), + updatedAt = completedAt, + ) + + service.createRequestCompletedIfAbsent(request, eventCreatedAt) + + val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { + invocation -> invocation.method.name == "insertIfAbsent" + } + val payload = objectMapper.readTree(insertInvocation.arguments[4] as String) + assertEquals(requestId.toString(), payload["requestId"].asText()) + assertEquals("COMPLETED", payload["status"].asText()) + assertNotNull(payload["completedAt"]) + assertEquals(100.0, payload["coveragePercent"].asDouble()) + assertNotNull(payload["matchedAt"]) + assertNotNull(payload["eventCreatedAt"]) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt new file mode 100644 index 0000000..1a9f7cc --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt @@ -0,0 +1,142 @@ +package org.nstart.dep265.requestservice.service + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RequestGridProjectionServiceTest { + + private val geometryService = GeometryService() + + @Test + fun `binds cells correctly for request in minus180 plus180 format crossing zero meridian`() { + val savedFragments = rebuildProjection( + geometry = "POLYGON ((-1 1, 1 1, 1 -1, -1 -1, -1 1))", + ) + + assertEquals(setOf(100L, 101L), savedFragments.map { fragment -> fragment.cell?.cellNum }.toSet()) + assertFragmentInCellBand(savedFragments, 100L, 359.0, 360.0) + assertFragmentInCellBand(savedFragments, 101L, 0.0, 1.0) + } + + @Test + fun `binds cells correctly for request in zero360 format crossing zero meridian`() { + val savedFragments = rebuildProjection( + geometry = "POLYGON ((359 1, 1 1, 1 -1, 359 -1, 359 1))", + ) + + assertEquals(setOf(100L, 101L), savedFragments.map { fragment -> fragment.cell?.cellNum }.toSet()) + assertFragmentInCellBand(savedFragments, 100L, 359.0, 360.0) + assertFragmentInCellBand(savedFragments, 101L, 0.0, 1.0) + } + + @Test + fun `request cells inherit request importance and make earth cell importance positive`() { + val savedFragments = rebuildProjection( + geometry = "POLYGON ((0 1, 1 1, 1 -1, 0 -1, 0 1))", + importance = 7.5, + ) + + assertTrue(savedFragments.isNotEmpty()) + assertTrue(savedFragments.all { fragment -> fragment.importance == 7.5 }) + + val earthCellImportance = savedFragments.sumOf { fragment -> + fragment.importance * ((fragment.coveragePercent ?: 0.0) / 100.0) + } + assertTrue(earthCellImportance > 0.0) + } + + private fun rebuildProjection( + geometry: String, + importance: Double = 10.0, + ): List { + val requestRepository = mock(RequestRepository::class.java) + val earthCellRepository = mock(EarthCellRepository::class.java) + val requestCellRepository = mock(RequestCellRepository::class.java) + val savedFragments = mutableListOf() + + val service = RequestGridProjectionService( + requestRepository = requestRepository, + earthCellRepository = earthCellRepository, + requestCellRepository = requestCellRepository, + geometryService = geometryService, + gridEnabled = true, + ) + + val cells = listOf( + EarthCellEntity( + cellId = 1L, + cellNum = 100L, + contour = "POLYGON ((359 1, 360 1, 360 -1, 359 -1, 359 1))", + ), + EarthCellEntity( + cellId = 2L, + cellNum = 101L, + contour = "POLYGON ((0 1, 1 1, 1 -1, 0 -1, 0 1))", + ), + ) + + doAnswer { invocation -> + val polygonWkt = invocation.getArgument(0) + val queryGeometry = geometryService.parsePolygonalGeometry(polygonWkt) + cells.filter { cell -> + val cellGeometry = geometryService.parsePolygonalGeometry(cell.contour) + queryGeometry.intersects(cellGeometry) + } + }.`when`(earthCellRepository).findIntersectingByContour(anyString()) + doAnswer { invocation -> + val fragments = invocation.getArgument>(0) + savedFragments += fragments + fragments + }.`when`(requestCellRepository).saveAll(org.mockito.ArgumentMatchers.anyList()) + + service.rebuildProjection( + RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 1.0, + importance = importance, + beginDateTime = LocalDateTime.now(), + endDateTime = LocalDateTime.now().plusDays(1), + highPriorityTransmit = false, + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.ACTIVE, + matchCount = 0, + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now(), + ) + ) + + return savedFragments + } + + private fun assertFragmentInCellBand( + savedFragments: List, + cellNum: Long, + expectedMinX: Double, + expectedMaxX: Double, + ) { + val fragment = savedFragments.first { requestCell -> requestCell.cell?.cellNum == cellNum } + val geometry = geometryService.parsePolygonalGeometry(fragment.contour) + + assertEquals(expectedMinX, geometry.envelopeInternal.minX) + assertEquals(expectedMaxX, geometry.envelopeInternal.maxX) + assertTrue(geometry.area > 0.0) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt new file mode 100644 index 0000000..b78136c --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt @@ -0,0 +1,255 @@ +package org.nstart.dep265.requestservice.service + +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Test +import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto +import org.nstart.dep265.requestservice.dto.RequestStatusDto +import org.nstart.dep265.requestservice.dto.SurveyTypeDto +import org.nstart.dep265.requestservice.PcpRequestServiceApplication +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.EarthCellRepository +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.util.UUID +import kotlin.test.assertEquals + +@SpringBootTest( + classes = [PcpRequestServiceApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = [ + "spring.config.import=", + "spring.cloud.config.enabled=false", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:request-list;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.kafka.listener.auto-startup=false", + "spring.kafka.consumer.group-id=request-service-test", + "app.kafka.topics.route=request-service-test-route", + ], +) +@Transactional +class RequestServiceListRequestsJpaTest @Autowired constructor( + private val earthCellRepository: EarthCellRepository, + private val requestRepository: RequestRepository, + private val service: RequestService, + @Suppress("unused") private val requestOpticsParamsRepository: RequestOpticsParamsRepository, + @Suppress("unused") private val requestRsaParamsRepository: RequestRsaParamsRepository, + @Suppress("unused") private val requestCellRepository: RequestCellRepository, + @Suppress("unused") private val entityManager: EntityManager, +) { + @Test + fun `list requests excludes deleted by default`() { + val activeId = saveRequest(status = RequestStatus.ACTIVE, createdAt = "2026-01-02T00:00:00") + saveRequest(status = RequestStatus.DELETED, createdAt = "2026-01-03T00:00:00") + + val response = service.listRequests(ListRequestsQueryDto()) + + assertEquals(listOf(activeId), response.items.map { request -> request.id }) + assertEquals("Тест1", response.items.single().name) + assertEquals(1, response.totalItems) + assertEquals(1, response.totalPages) + } + + @Test + fun `list requests includes deleted when includeDeleted is true`() { + val activeId = saveRequest(status = RequestStatus.ACTIVE, createdAt = "2026-01-02T00:00:00") + val deletedId = saveRequest(status = RequestStatus.DELETED, createdAt = "2026-01-03T00:00:00") + + val response = service.listRequests(ListRequestsQueryDto(includeDeleted = true)) + + assertEquals(listOf(deletedId, activeId), response.items.map { request -> request.id }) + assertEquals(2, response.totalItems) + } + + @Test + fun `list requests filters by status`() { + val activeId = saveRequest(status = RequestStatus.ACTIVE) + saveRequest(status = RequestStatus.ACCEPTED) + + val response = service.listRequests(ListRequestsQueryDto(status = RequestStatusDto.ACTIVE)) + + assertEquals(listOf(activeId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests filters by survey type`() { + val rsaId = saveRequest(surveyType = RequestSurveyType.RSA) + saveRequest(surveyType = RequestSurveyType.OPTICS) + + val response = service.listRequests(ListRequestsQueryDto(surveyType = SurveyTypeDto.RSA)) + + assertEquals(listOf(rsaId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests filters by kpp in database collection`() { + val matchedId = saveRequest(kpp = mutableListOf(1, 2)) + saveRequest(kpp = mutableListOf(3, 4)) + + val response = service.listRequests(ListRequestsQueryDto(kpp = 2)) + + assertEquals(listOf(matchedId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests filters by high priority transmit`() { + val highPriorityId = saveRequest(highPriorityTransmit = true) + saveRequest(highPriorityTransmit = false) + + val response = service.listRequests(ListRequestsQueryDto(highPriorityTransmit = true)) + + assertEquals(listOf(highPriorityId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests filters by begin date range inclusively`() { + saveRequest(beginDateTime = "2026-01-01T00:00:00") + val matchedId = saveRequest(beginDateTime = "2026-01-15T00:00:00") + saveRequest(beginDateTime = "2026-02-01T00:00:00", endDateTime = "2026-02-28T00:00:00") + + val response = service.listRequests( + ListRequestsQueryDto( + beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), + beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + ) + ) + + assertEquals(listOf(matchedId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests filters by end date range inclusively`() { + saveRequest(endDateTime = "2026-01-09T00:00:00") + val matchedId = saveRequest(endDateTime = "2026-01-20T00:00:00") + saveRequest(endDateTime = "2026-02-01T00:00:00") + + val response = service.listRequests( + ListRequestsQueryDto( + endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), + endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + ) + ) + + assertEquals(listOf(matchedId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests paginates with total items and total pages from database page`() { + saveRequest(createdAt = "2026-01-03T00:00:00") + saveRequest(createdAt = "2026-01-02T00:00:00") + val oldestId = saveRequest(createdAt = "2026-01-01T00:00:00") + + val response = service.listRequests(ListRequestsQueryDto(page = 1, size = 2)) + + assertEquals(listOf(oldestId), response.items.map { request -> request.id }) + assertEquals(1, response.page) + assertEquals(2, response.size) + assertEquals(3, response.totalItems) + assertEquals(2, response.totalPages) + } + + @Test + fun `list requests sorts by created at descending by default`() { + val oldestId = saveRequest(createdAt = "2026-01-01T00:00:00") + val newestId = saveRequest(createdAt = "2026-01-03T00:00:00") + val middleId = saveRequest(createdAt = "2026-01-02T00:00:00") + + val response = service.listRequests(ListRequestsQueryDto()) + + assertEquals(listOf(newestId, middleId, oldestId), response.items.map { request -> request.id }) + } + + @Test + fun `list requests supports whitelisted sort field`() { + val latestBeginId = saveRequest(beginDateTime = "2026-01-03T00:00:00") + val earliestBeginId = saveRequest(beginDateTime = "2026-01-01T00:00:00") + + val response = service.listRequests(ListRequestsQueryDto(sort = "beginDateTime,asc")) + + assertEquals(listOf(earliestBeginId, latestBeginId), response.items.map { request -> request.id }) + } + + @Test + fun `get request with cells loads request cell projections with earth cell numbers`() { + val requestId = saveRequest() + val earthCell = earthCellRepository.saveAndFlush( + EarthCellEntity( + cellNum = 32400, + latitude = 55.0, + longitude = 37.0, + importance = 5.0, + contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ) + ) + requestCellRepository.saveAndFlush( + RequestCellEntity( + requestId = requestId, + coveragePercent = 25.0, + importance = 7.0, + contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", + cell = earthCell, + ) + ) + entityManager.clear() + + val response = service.getRequestWithCells(requestId) + + assertEquals(requestId, response?.request?.id) + assertEquals("Тест1", response?.request?.name) + val cell = response?.cells?.single() + assertEquals(32400L, cell?.cellNum) + assertEquals(25.0, cell?.coveragePercent) + assertEquals(7.0, cell?.importance) + assertEquals("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", cell?.contour) + } + + private fun saveRequest( + status: RequestStatus = RequestStatus.ACTIVE, + surveyType: RequestSurveyType = RequestSurveyType.OPTICS, + kpp: MutableList = mutableListOf(1), + highPriorityTransmit: Boolean = false, + beginDateTime: String = "2026-01-01T00:00:00", + endDateTime: String = "2026-01-31T00:00:00", + createdAt: String = "2026-01-01T00:00:00", + ): UUID { + val id = UUID.randomUUID() + val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))" + requestRepository.saveAndFlush( + RequestEntity( + id = id, + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 0.5, + coverageRequiredPercent = 100.0, + importance = 10.0, + beginDateTime = LocalDateTime.parse(beginDateTime), + endDateTime = LocalDateTime.parse(endDateTime), + kpp = kpp, + highPriorityTransmit = highPriorityTransmit, + surveyType = surveyType, + status = status, + createdAt = LocalDateTime.parse(createdAt), + updatedAt = LocalDateTime.parse(createdAt), + deletedAt = if (status == RequestStatus.DELETED) LocalDateTime.parse(createdAt) else null, + ) + ) + return id + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt new file mode 100644 index 0000000..0139de1 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt @@ -0,0 +1,314 @@ +package org.nstart.dep265.requestservice.service + +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.isNull +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto +import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto +import org.nstart.dep265.requestservice.dto.OpticsParamsDto +import org.nstart.dep265.requestservice.dto.OpticsResultTypeDto +import org.nstart.dep265.requestservice.entity.EarthCellEntity +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestCellEntity +import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity +import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.RequestCellRepository +import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.util.Optional +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RequestServiceTest { + private val requestRepository = mock(RequestRepository::class.java) + private val requestOpticsParamsRepository = mock(RequestOpticsParamsRepository::class.java) + private val requestRsaParamsRepository = mock(RequestRsaParamsRepository::class.java) + private val requestCellRepository = mock(RequestCellRepository::class.java) + private val requestGridProjectionService = mock(RequestGridProjectionService::class.java) + private val entityManager = mock(EntityManager::class.java) + + private val service = RequestService( + requestRepository = requestRepository, + requestOpticsParamsRepository = requestOpticsParamsRepository, + requestRsaParamsRepository = requestRsaParamsRepository, + requestCellRepository = requestCellRepository, + geometryService = GeometryService(), + requestGridProjectionService = requestGridProjectionService, + entityManager = entityManager, + ) + + init { + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(requestOpticsParamsRepository) + .save(any(RequestOpticsParamsEntity::class.java)) + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(requestRsaParamsRepository) + .save(any(RequestRsaParamsEntity::class.java)) + doReturn(Optional.empty()) + .`when`(requestOpticsParamsRepository) + .findById(any()) + doReturn(Optional.empty()) + .`when`(requestRsaParamsRepository) + .findById(any()) + doReturn(emptyList()) + .`when`(requestCellRepository) + .findWithCellByRequestId(uuidMatcher()) + } + + @Test + fun `create request inserts new request with default required coverage and input importance`() { + val persistedRequests = mutableListOf() + doReturn(false) + .`when`(requestRepository) + .existsById(REQUEST_ID) + doAnswer { invocation -> + persistedRequests += invocation.getArgument(0) + null + }.`when`(entityManager).persist(any(RequestEntity::class.java)) + + val response = service.createRequest(createRequestDto()) + + assertEquals(REQUEST_ID, response.id) + assertEquals(REQUEST_NAME, response.name) + assertEquals(REQUEST_IMPORTANCE, response.importance) + assertEquals(1, persistedRequests.size) + assertEquals(REQUEST_NAME, persistedRequests.single().name) + assertEquals(100.0, persistedRequests.single().coverageRequiredPercent) + assertEquals(REQUEST_IMPORTANCE, persistedRequests.single().importance) + verify(requestRepository, never()).save(any(RequestEntity::class.java)) + verify(requestGridProjectionService).rebuildProjection(persistedRequests.single()) + } + + @Test + fun `get request by id returns name and importance`() { + val request = existingRequest() + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findById(REQUEST_ID) + + val response = service.getRequestById(REQUEST_ID) + + assertEquals(REQUEST_NAME, response?.name) + assertEquals(REQUEST_IMPORTANCE, response?.importance) + } + + @Test + fun `get request with cells returns request read model and request cell projection fields`() { + val request = existingRequest() + val earthCell = EarthCellEntity( + cellId = 101, + cellNum = 32400, + latitude = 55.0, + longitude = 37.0, + importance = 5.0, + contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", + ) + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findById(REQUEST_ID) + doReturn( + listOf( + RequestCellEntity( + requestId = REQUEST_ID, + coveragePercent = 25.0, + importance = 7.0, + contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", + cell = earthCell, + ) + ) + ) + .`when`(requestCellRepository) + .findWithCellByRequestId(REQUEST_ID) + + val response = service.getRequestWithCells(REQUEST_ID) + + assertEquals(REQUEST_ID, response?.request?.id) + assertEquals(REQUEST_NAME, response?.request?.name) + assertEquals(REQUEST_IMPORTANCE, response?.request?.importance) + + val cell = response?.cells?.single() + assertEquals(32400L, cell?.cellNum) + assertEquals(25.0, cell?.coveragePercent) + assertEquals(7.0, cell?.importance) + assertEquals("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", cell?.contour) + verify(requestCellRepository).findWithCellByRequestId(REQUEST_ID) + } + + @Test + fun `get request with cells returns empty cells for request without projections`() { + doReturn(Optional.of(existingRequest())) + .`when`(requestRepository) + .findById(REQUEST_ID) + doReturn(emptyList()) + .`when`(requestCellRepository) + .findWithCellByRequestId(REQUEST_ID) + + val response = service.getRequestWithCells(REQUEST_ID) + + assertEquals(REQUEST_ID, response?.request?.id) + assertEquals(emptyList(), response?.cells) + } + + @Test + fun `get request with cells returns deleted request with empty cells`() { + val request = existingRequest().also { existingRequest -> + existingRequest.status = RequestStatus.DELETED + existingRequest.deletedAt = LocalDateTime.parse("2026-02-01T00:00:00") + } + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findById(REQUEST_ID) + doReturn(emptyList()) + .`when`(requestCellRepository) + .findWithCellByRequestId(REQUEST_ID) + + val response = service.getRequestWithCells(REQUEST_ID) + + assertEquals(RequestStatus.DELETED.name, response?.request?.status?.name) + assertEquals(emptyList(), response?.cells) + } + + @Test + fun `get request with cells returns null when request is absent`() { + doReturn(Optional.empty()) + .`when`(requestRepository) + .findById(REQUEST_ID) + + val response = service.getRequestWithCells(REQUEST_ID) + + assertEquals(null, response) + verify(requestCellRepository, never()).findWithCellByRequestId(REQUEST_ID) + } + + @Test + fun `list requests returns name and importance in summary`() { + doReturn(PageImpl(listOf(existingRequest()))) + .`when`(requestRepository) + .findRequests( + anyBoolean(), + deletedStatusMatcher(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + pageableMatcher(), + ) + + val response = service.listRequests(ListRequestsQueryDto()) + + assertEquals(REQUEST_NAME, response.items.single().name) + assertEquals(REQUEST_IMPORTANCE, response.items.single().importance) + } + + @Test + fun `create request rejects duplicate id without touching existing request state`() { + doReturn(true) + .`when`(requestRepository) + .existsById(REQUEST_ID) + + assertFailsWith { + service.createRequest(createRequestDto()) + } + + verify(entityManager, never()).persist(any(RequestEntity::class.java)) + verify(requestRepository, never()).save(any(RequestEntity::class.java)) + verifyNoInteractions(requestGridProjectionService) + verify(requestCellRepository, never()).deleteByRequestId(REQUEST_ID) + } + + @Test + fun `delete request soft deletes request and removes request cells projection`() { + val request = existingRequest() + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findById(REQUEST_ID) + doAnswer { invocation -> invocation.getArgument(0) } + .`when`(requestRepository) + .save(any(RequestEntity::class.java)) + + val response = service.deleteRequest(REQUEST_ID) + + assertEquals(REQUEST_ID, response?.id) + assertEquals(RequestStatus.DELETED, request.status) + verify(requestCellRepository).deleteByRequestId(REQUEST_ID) + } + + private fun createRequestDto(): CreateRequestRequestDto { + return CreateRequestRequestDto( + id = REQUEST_ID, + name = REQUEST_NAME, + geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + importance = REQUEST_IMPORTANCE, + beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), + endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + optics = OpticsParamsDto( + resultType = OpticsResultTypeDto.PANCHROMATIC, + resolution = 1.0, + ), + ) + } + + private fun existingRequest(): RequestEntity { + val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))" + return RequestEntity( + id = REQUEST_ID, + name = REQUEST_NAME, + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 0.5, + coverageRequiredPercent = 100.0, + importance = REQUEST_IMPORTANCE, + beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"), + endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"), + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.ACTIVE, + matchCount = 1, + completedAt = null, + createdAt = LocalDateTime.parse("2025-12-31T00:00:00"), + updatedAt = LocalDateTime.parse("2025-12-31T00:00:00"), + ) + } + + private companion object { + val REQUEST_ID: UUID = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6") + const val REQUEST_NAME = "Тест1" + const val REQUEST_IMPORTANCE = 10.0 + } + + private fun deletedStatusMatcher(): RequestStatus { + any(RequestStatus::class.java) + return RequestStatus.DELETED + } + + private fun pageableMatcher(): Pageable { + any(Pageable::class.java) + return PageRequest.of(0, 50) + } + + private fun uuidMatcher(): UUID { + any(UUID::class.java) + return REQUEST_ID + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt new file mode 100644 index 0000000..6af3400 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt @@ -0,0 +1,412 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockingDetails +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.nstart.dep265.requestservice.dto.RouteAngleRangeDto +import org.nstart.dep265.requestservice.dto.RouteDto +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.nstart.dep265.requestservice.repository.RequestRouteMatchRepository +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class RouteMatchingServiceTest { + + @Test + fun `route intersecting original and remaining stores contributing match and updates coverage`() { + val requestRepository = mock(RequestRepository::class.java) + val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val geometryService = GeometryService() + val routeMatchingService = routeMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + outboxEventRepository = outboxEventRepository, + geometryService = geometryService, + ) + + val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalEnd = intervalBegin.plusHours(1) + val request = request( + beginDateTime = intervalBegin, + endDateTime = intervalEnd, + geometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))", + remainingGeometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))", + geometryArea = 4.0, + remainingArea = 4.0, + status = RequestStatus.ACTIVE, + ) + val route = route( + intervalBegin = intervalBegin.plusMinutes(1), + intervalEnd = intervalBegin.plusMinutes(2), + geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + ) + + stubCandidate(requestRepository, requestRouteMatchRepository, request, route) + + routeMatchingService.process(route) + + val routeMatch = captureSavedRouteMatch(requestRouteMatchRepository) + assertEquals(request.id, routeMatch.requestId) + assertEquals(route.routeId, routeMatch.routeId) + assertTrue(routeMatch.contributesToCoverage) + assertTrue(routeMatch.originalIntersectionArea > 0.0) + assertTrue(routeMatch.appliedIntersectionArea > 0.0) + assertTrue(routeMatch.coverageDeltaPercent > 0.0) + assertEquals(3.0, request.remainingArea) + assertEquals(1, request.matchCount) + assertEquals(RequestStatus.ACTIVE, request.status) + verify(requestRepository).findByIdForUpdate(request.id) + verify(requestRepository).save(request) + verifyNoInteractions(outboxEventRepository) + } + + @Test + fun `route intersecting original but not remaining stores non contributing match without coverage update`() { + val requestRepository = mock(RequestRepository::class.java) + val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val geometryService = GeometryService() + val routeMatchingService = routeMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + outboxEventRepository = outboxEventRepository, + geometryService = geometryService, + ) + + val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalEnd = intervalBegin.plusHours(1) + val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))" + val request = request( + beginDateTime = intervalBegin, + endDateTime = intervalEnd, + geometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))", + remainingGeometry = originalRemainingGeometry, + geometryArea = 4.0, + remainingArea = 1.0, + status = RequestStatus.ACTIVE, + ) + val route = route( + intervalBegin = intervalBegin.plusMinutes(1), + intervalEnd = intervalBegin.plusMinutes(2), + geometry = "POLYGON((0 0, 0.5 0, 0.5 0.5, 0 0.5, 0 0))", + ) + + stubCandidate(requestRepository, requestRouteMatchRepository, request, route) + + routeMatchingService.process(route) + + val routeMatch = captureSavedRouteMatch(requestRouteMatchRepository) + assertEquals(request.id, routeMatch.requestId) + assertEquals(route.routeId, routeMatch.routeId) + assertEquals(false, routeMatch.contributesToCoverage) + assertTrue(routeMatch.originalIntersectionArea > 0.0) + assertNull(routeMatch.appliedIntersectionGeometry) + assertEquals(0.0, routeMatch.appliedIntersectionArea) + assertEquals(0.0, routeMatch.coverageDeltaPercent) + assertEquals(originalRemainingGeometry, request.remainingGeometry) + assertEquals(1.0, request.remainingArea) + assertEquals(0, request.matchCount) + verify(requestRepository, never()).save(request) + verifyNoInteractions(outboxEventRepository) + } + + @Test + fun `route outside original geometry does not create match or update request`() { + val requestRepository = mock(RequestRepository::class.java) + val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val geometryService = GeometryService() + val routeMatchingService = routeMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + outboxEventRepository = outboxEventRepository, + geometryService = geometryService, + ) + + val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val request = request( + beginDateTime = intervalBegin, + endDateTime = intervalBegin.plusHours(1), + status = RequestStatus.ACTIVE, + ) + val route = route( + intervalBegin = intervalBegin.plusMinutes(1), + intervalEnd = intervalBegin.plusMinutes(2), + geometry = "POLYGON((5 5, 6 5, 6 6, 5 6, 5 5))", + ) + + stubCandidate(requestRepository, requestRouteMatchRepository, request, route) + + routeMatchingService.process(route) + + verify(requestRouteMatchRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any()) + verify(requestRepository, never()).save(request) + verifyNoInteractions(outboxEventRepository) + assertEquals(1.0, request.remainingArea) + assertEquals(0, request.matchCount) + } + + @Test + fun `duplicate route match after lock skips geometry and coverage update`() { + val requestRepository = mock(RequestRepository::class.java) + val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val geometryService = GeometryService() + val routeMatchingService = routeMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + outboxEventRepository = outboxEventRepository, + geometryService = geometryService, + ) + + val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val request = request( + beginDateTime = intervalBegin, + endDateTime = intervalBegin.plusHours(1), + status = RequestStatus.ACTIVE, + ) + val route = route( + intervalBegin = intervalBegin.plusMinutes(1), + intervalEnd = intervalBegin.plusMinutes(2), + geometry = request.geometry, + ) + + doReturn(listOf(request.id)) + .`when`(requestRepository) + .findRequestIdsForRoute( + listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE), + route.intervalBegin, + route.intervalEnd, + ) + doReturn(false, true) + .`when`(requestRouteMatchRepository) + .existsByRequestIdAndRouteId(request.id, route.routeId) + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findByIdForUpdate(request.id) + + routeMatchingService.process(route) + + verify(requestRouteMatchRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any()) + verify(requestRepository, never()).save(request) + verifyNoInteractions(outboxEventRepository) + assertEquals(1.0, request.remainingArea) + assertEquals(0, request.matchCount) + } + + @Test + fun `duplicate unique violation on match insert is handled as duplicate delivery`() { + val requestRepository = mock(RequestRepository::class.java) + val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val geometryService = GeometryService() + val routeMatchingService = routeMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + outboxEventRepository = outboxEventRepository, + geometryService = geometryService, + ) + + val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val request = request( + beginDateTime = intervalBegin, + endDateTime = intervalBegin.plusHours(1), + status = RequestStatus.ACTIVE, + ) + val route = route( + intervalBegin = intervalBegin.plusMinutes(1), + intervalEnd = intervalBegin.plusMinutes(2), + geometry = request.geometry, + ) + + stubCandidate(requestRepository, requestRouteMatchRepository, request, route) + doThrow(DataIntegrityViolationException("duplicate key violates unique constraint uk_request_route_match")) + .`when`(requestRouteMatchRepository) + .saveAndFlush(org.mockito.ArgumentMatchers.any()) + + routeMatchingService.process(route) + + verify(requestRepository, never()).save(request) + verifyNoInteractions(outboxEventRepository) + assertEquals(1.0, request.remainingArea) + assertEquals(0, request.matchCount) + } + + @Test + fun `route completing request creates exactly one request completed outbox event`() { + val requestRepository = mock(RequestRepository::class.java) + val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val geometryService = GeometryService() + val routeMatchingService = routeMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + outboxEventRepository = outboxEventRepository, + geometryService = geometryService, + ) + + val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val request = request( + beginDateTime = intervalBegin, + endDateTime = intervalBegin.plusHours(1), + status = RequestStatus.ACTIVE, + ) + val route = route( + intervalBegin = intervalBegin.plusMinutes(1), + intervalEnd = intervalBegin.plusMinutes(2), + geometry = request.geometry, + ) + + stubCandidate(requestRepository, requestRouteMatchRepository, request, route) + + routeMatchingService.process(route) + + val insertInvocations = mockingDetails(outboxEventRepository).invocations.filter { + invocation -> invocation.method.name == "insertIfAbsent" + } + assertEquals(1, insertInvocations.size) + assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1]) + assertEquals(request.id, insertInvocations.single().arguments[3]) + assertEquals(RequestStatus.COMPLETED, request.status) + } + + private fun routeMatchingService( + requestRepository: RequestRepository, + requestRouteMatchRepository: RequestRouteMatchRepository, + outboxEventRepository: OutboxEventRepository, + geometryService: GeometryService, + ): RouteMatchingService { + return RouteMatchingService( + requestRepository = requestRepository, + requestRouteMatchRepository = requestRouteMatchRepository, + requestValidationService = RequestValidationService(geometryService), + geometryService = geometryService, + requestLifecycleService = RequestLifecycleService(geometryService), + requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService), + routeMatchingTransactionOperations = ImmediateTransactionOperations(), + ) + } + + private fun requestCompletedOutboxService( + outboxEventRepository: OutboxEventRepository, + geometryService: GeometryService, + ): RequestCompletedOutboxService { + return RequestCompletedOutboxService( + outboxEventRepository = outboxEventRepository, + objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules(), + geometryService = geometryService, + ) + } + + private fun stubCandidate( + requestRepository: RequestRepository, + requestRouteMatchRepository: RequestRouteMatchRepository, + request: RequestEntity, + route: RouteDto, + ) { + doReturn(listOf(request.id)) + .`when`(requestRepository) + .findRequestIdsForRoute( + listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE), + route.intervalBegin, + route.intervalEnd, + ) + doReturn(false) + .`when`(requestRouteMatchRepository) + .existsByRequestIdAndRouteId(request.id, route.routeId) + doReturn(Optional.of(request)) + .`when`(requestRepository) + .findByIdForUpdate(request.id) + doReturn(request) + .`when`(requestRepository) + .save(request) + } + + private fun captureSavedRouteMatch( + requestRouteMatchRepository: RequestRouteMatchRepository, + ): RequestRouteMatchEntity { + val routeMatchCaptor = ArgumentCaptor.forClass(RequestRouteMatchEntity::class.java) + verify(requestRouteMatchRepository).saveAndFlush(routeMatchCaptor.capture()) + return routeMatchCaptor.value + } + + private fun request( + beginDateTime: LocalDateTime, + endDateTime: LocalDateTime, + geometry: String = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + remainingGeometry: String = geometry, + geometryArea: Double = 1.0, + remainingArea: Double = geometryArea, + status: RequestStatus, + completedAt: LocalDateTime? = null, + ): RequestEntity { + val createdAt = beginDateTime.minusHours(1) + return RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = remainingGeometry, + geometryArea = geometryArea, + remainingArea = remainingArea, + importance = 10.0, + beginDateTime = beginDateTime, + endDateTime = endDateTime, + highPriorityTransmit = false, + surveyType = RequestSurveyType.OPTICS, + status = status, + matchCount = 0, + completedAt = completedAt, + createdAt = createdAt, + updatedAt = createdAt, + ) + } + + private fun route( + intervalBegin: LocalDateTime, + intervalEnd: LocalDateTime, + geometry: String, + ): RouteDto { + return RouteDto( + routeId = UUID.randomUUID(), + intervalBegin = intervalBegin, + intervalEnd = intervalEnd, + rollAngle = RouteAngleRangeDto( + min = 10.0, + max = 14.0, + ), + geometry = geometry, + ) + } + + private class ImmediateTransactionOperations : TransactionOperations { + override fun execute(action: TransactionCallback): T { + val status: TransactionStatus = SimpleTransactionStatus() + return action.doInTransaction(status) + } + } +} diff --git a/services/pcp-request-service/src/test/resources/application.yaml b/services/pcp-request-service/src/test/resources/application.yaml new file mode 100644 index 0000000..ec923c9 --- /dev/null +++ b/services/pcp-request-service/src/test/resources/application.yaml @@ -0,0 +1,6 @@ +spring: + config: + import: "" + cloud: + config: + enabled: false diff --git a/services/pcp-route-processing-service/README.md b/services/pcp-route-processing-service/README.md new file mode 100644 index 0000000..945e289 --- /dev/null +++ b/services/pcp-route-processing-service/README.md @@ -0,0 +1,35 @@ +# route-passport-kafka-consumer + +Минимальный сервис на Kotlin + Spring Boot, который: +- слушает Kafka topic; +- получает в сообщении JSON паспорта маршрута; +- парсит JSON в DTO; +- больше ничего не делает. + +## Что внутри + +- `RoutePassportConsumerApplication` — точка входа Spring Boot. +- `RoutePassportMessageListener` — Kafka listener. +- `RoutePassportJsonParserService` — сервис парсинга входного JSON в DTO. +- `RoutePassportDto` — итоговый DTO из `pcp-types-lib`. +- `RawPassportEnvelopeDto` и связанные `Raw*` DTO — модель входного JSON из `pcp-types-lib`. + +## Как запустить + +1. Поднять Kafka. +2. Указать адрес брокера в `application.yml` или через переменную окружения `KAFKA_BOOTSTRAP_SERVERS`. +3. При необходимости указать topic через `APP_KAFKA_TOPIC`. +4. Запустить приложение: + +```bash +./gradlew bootRun +``` + +## Сообщение, которое ожидает сервис + +Сервис ожидает, что value Kafka-сообщения — это JSON того же формата, что и `src/test/resources/passport_example.json`. +Поле `geometry` должно приходить строкой в формате WKT. + +## Примечание + +Поскольку в запросе был указан просто «слушает очередь», реализация сделана через Kafka topic как наиболее типичный вариант для стека Kotlin + Spring и Kafka-ориентированного сервиса. diff --git a/services/pcp-route-processing-service/build.gradle.kts b/services/pcp-route-processing-service/build.gradle.kts new file mode 100644 index 0000000..178b2b7 --- /dev/null +++ b/services/pcp-route-processing-service/build.gradle.kts @@ -0,0 +1,57 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +version = "1.0.0" + +description = "Входная точка маршрутов от портала для их последующей обработки в ПЦП" + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("tools.jackson.module:jackson-module-kotlin") + implementation("org.locationtech.jts:jts-core:1.19.0") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.kafka:spring-kafka-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/services/pcp-route-processing-service/planToDo.md b/services/pcp-route-processing-service/planToDo.md new file mode 100644 index 0000000..b071188 --- /dev/null +++ b/services/pcp-route-processing-service/planToDo.md @@ -0,0 +1,2326 @@ +# План работ: доведение `pcp-route-processing-service` / route-passport до production-ready состояния + +Дата подготовки: 2026-04-28 +Источник анализа: архив `pcp.zip`, сервис `services/pcp-route-processing-service`, shared library `libs/pcp-types-lib`. + +--- + +## 0. Цель работ + +Нужно усилить текущую реализацию обработки паспорта маршрута так, чтобы сервис был готов к production-сценарию: + +1. входной Kafka offset не должен коммититься до надежного сохранения результата обработки; +2. принятый маршрут должен сохраняться в БД вместе с геометрией и параметрами; +3. публикация downstream-событий должна идти через outbox, а не напрямую из Kafka listener; +4. ошибки должны быть классифицированы и обрабатываться через bounded retry + DLQ/DLT; +5. ручную GeoJSON → WKT конвертацию нужно заменить на библиотечную; +6. текущая бизнес-семантика `routeStatus = PROCESSED` остается без изменений; +7. flow остается Kafka-only, BPM/Camunda в рамках этой задачи не добавляется. + +--- + +## 1. Текущая реализация, от которой отталкиваемся + +### 1.1. Текущий поток + +```text +pcp.request.survey-georeference.v1 + ↓ +RoutePassportMessageListener.listen(message: String) + ↓ +RoutePassportJsonParserService.parse(message) + ↓ +ParsedRoutePassportMessage(RoutePassportDto + trace/correlation metadata) + ↓ +RoutePassportKafkaPublisherService.publish(parsedMessage) + ├── pcp.route.in.v1 / ModeStatusChangedEvent + └── pcp.route.georeference.v1 / RouteGeoRefEvent +``` + +### 1.2. Ключевые текущие классы + +| Файл/класс | Текущая роль | +|---|---| +| `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt` | Kafka listener входного topic `pcp.request.survey-georeference.v1`. Сейчас парсит сообщение и сразу вызывает publisher. | +| `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt` | Парсит raw envelope в `RoutePassportDto`; вручную конвертирует GeoJSON geometry в WKT. | +| `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt` | Публикует один и тот же `RoutePassportDto` в два output topic. Сейчас publish асинхронный и ошибка только логируется. | +| `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt` | Явно создает `ConsumerFactory` и `ConcurrentKafkaListenerContainerFactory`. | +| `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt` | Явно создает `ProducerFactory` и `KafkaTemplate`. | +| `services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt` | Типизированные имена input/output topic. | +| `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt` | Shared DTO: `RoutePassportDto`, `AngleRangeDto`. | +| `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/ParsedRoutePassportMessageDTO.kt` | Shared DTO: `ParsedRoutePassportMessage`, metadata. | +| `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RawPassportJsonDTO.kt` | Raw DTO входного envelope/STAC-like паспорта. В рамках этой задачи не переносится. | +| `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt` | Общая Kafka envelope-модель. В рамках этой задачи не меняется. | +| `libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/PcpKafkaEvent.kt` | Типы Kafka-событий, включая `RouteGeoRefEvent` и `ModeStatusChangedEvent`. | + +### 1.3. Главные текущие проблемы + +| Проблема | Почему это риск | +|---|---| +| `enable-auto-commit: true` в `config-repo/pcp-route-processing-service.yaml` | Offset может быть закоммичен до надежной обработки сообщения. | +| Listener сразу вызывает Kafka publisher | Если publish упал, входное сообщение уже могло быть считано/закоммичено. | +| Publisher отправляет два события асинхронно и только логирует ошибку | Возможна частичная публикация: один downstream topic получил событие, второй нет. | +| Нет БД и outbox | Невозможно надежно восстановить публикацию downstream-событий после сбоя Kafka/сервиса. | +| Нет явного DLQ/DLT и bounded retry | Bad input может зависнуть в бесконечных retry или обрабатываться неявным default behavior Spring Kafka. | +| Ручная GeoJSON → WKT конвертация | Риск некорректной геометрии, слабая валидация, ручная поддержка Polygon/MultiPolygon. | +| `routeStatus` всегда `PROCESSED` | Это остается осознанным текущим поведением. Не исправляем в этой задаче. | + +--- + +## 2. Принятые решения по scope + +### 2.1. Делаем + +| № | Решение | +|---:|---| +| 1 | Перевести Kafka consumer на controlled commit: offset подтверждается только после успешного DB commit. | +| 2 | Настроить bounded retry + DLQ/DLT для входных сообщений. | +| 3 | Добавить БД для `pcp-route-processing-service`. | +| 4 | Сохранять принятый маршрут, геометрию, параметры и статус в БД. | +| 5 | Реализовать outbox через эту БД. | +| 6 | Убрать прямую downstream-публикацию из listener. | +| 7 | Публиковать `ModeStatusChangedEvent` и `RouteGeoRefEvent` из outbox worker. | +| 8 | Добавить producer reliability настройки: `acks=all`, retries, idempotence. | +| 9 | Добавить классификацию ошибок: `SUCCESS`, `RETRYABLE`, `BUSINESS/CONTRACT`, `FATAL`. | +| 10 | Заменить ручной GeoJSON → WKT converter на библиотечный подход. | +| 11 | Оставить flow Kafka-only. BPM/Camunda не добавлять. | +| 12 | Добавить минимальные тесты под измененную логику. | + +### 2.2. Не делаем в этой задаче + +| Пункт | Решение | Риск, который остается | +|---|---|---| +| Чтение `routeStatus` из входного сообщения | Не делаем. Оставляем `PROCESSED` как сейчас. | Если внешний контур начнет присылать `FAILED`, `CANCELED`, `DROPPED`, сервис это проигнорирует. | +| Замена Kafka key с `traceId` на `routeId`/`correlationId` | Не делаем. | Partitioning/ordering остаются привязанными к traceId, а не к business key. | +| Fallback `traceId`/`correlationId` | Не делаем. | Пустые trace/correlation значения могут пройти дальше. | +| README/fixtures | Не трогаем. | Документация и fixtures могут остаться несогласованными с кодом. | +| E2E serialization test producer → consumers | Не делаем отдельным пунктом. | Контракт producer/consumer не будет полностью проверен end-to-end. | +| `RouteProcessingTestController` | Не трогаем. | Тестовый endpoint остается в main-коде. | +| `OpenApiConfig`/springdoc | Не трогаем. | Лишняя REST/OpenAPI инфраструктура остается. | +| Перенос raw DTO из shared lib | Не делаем. | Shared lib продолжает содержать внутренний raw input contract route-processing-service. | +| Упрощение `KafkaEvent`/ClassGraph | Не делаем. | Существующая абстракция остается как есть. | +| Упрощение Kafka config через `KafkaProperties` | Не делаем. | Но точечно добавляем нужные reliability properties в текущий custom config. | +| Изменение `KafkaMessage` | Не делаем. | `source = "Example"` и mutable поле остаются в shared contract. | +| Полные Kafka transactions | Не делаем. | Достигаем надежности через DB outbox и at-least-once. | +| Camunda/BPM flow | Не делаем. | Сервис остается Kafka-only. | + +--- + +## 3. Целевая архитектура + +### 3.1. Целевой поток обработки input message + +```text +pcp.request.survey-georeference.v1 + ↓ +RoutePassportMessageListener + ↓ +RoutePassportJsonParserService + ↓ +RouteGeometryConverter + ↓ +RoutePassportIngestionService @Transactional + ├── UPSERT route_passports + └── INSERT/UPSERT route_passport_outbox x2 + ├── ModeStatusChangedEvent -> pcp.route.in.v1 + └── RouteGeoRefEvent -> pcp.route.georeference.v1 + ↓ +Kafka offset ack/commit only after DB commit +``` + +### 3.2. Целевой поток публикации outbox + +```text +route_passport_outbox PENDING rows + ↓ +RoutePassportOutboxWorker @Scheduled + ↓ +RoutePassportOutboxService.claimDueBatch() + ↓ +RoutePassportKafkaPublisherService.publish(outboxRow) + ↓ +Kafka output topic + ↓ +mark PUBLISHED or schedule retry / mark FAILED +``` + +### 3.3. Целевой flow ошибок + +```text +Invalid JSON / invalid contract / unsupported geometry + ↓ +RoutePassportContractException + ↓ +DefaultErrorHandler non-retryable + ↓ +DLQ/DLT topic + +Temporary DB error during ingest + ↓ +Retry with bounded backoff + ↓ +Success OR DLQ/DLT after max attempts + +Kafka output publish error in outbox worker + ↓ +outbox row remains retryable + ↓ +next_attempt_at + attempts + ↓ +PUBLISHED OR FAILED after max attempts +``` + +--- + +## 4. Database naming and ownership + +### 4.1. Рекомендуемое имя БД + +Использовать БД: + +```text +pcp_route_processing +``` + +Причина: БД принадлежит сервису `pcp-route-processing-service`, а не отдельному DTO. Это соответствует подходу service-owned database. + +Человеческое описание: + +```text +Route Processing DB — БД хранения принятых route passport и outbox событий route-processing-service. +``` + +### 4.2. Не использовать + +```text +pcp-route-passport-bd +``` + +Причины: + +1. дефисы неудобны для PostgreSQL database identifiers; +2. `bd` лучше не использовать, стандартнее `db`, но в имени БД суффикс вообще не нужен; +3. БД должна называться по сервису/домейну, а не по техническому слову “passport DTO”. + +--- + +## 5. Новая структура пакетов + +Текущий base package: + +```text +space.nstart.pcp.routepassportconsumer +``` + +Предлагаемая структура: + +```text +space.nstart.pcp.routepassportconsumer +├── config +│ ├── KafkaConsumerConfig.kt +│ ├── KafkaProducerConfig.kt +│ ├── KafkaTopicsProperties.kt +│ ├── RoutePassportOutboxProperties.kt +│ └── RoutePassportStorageProperties.kt # optional, если нужны storage-specific настройки +├── listener +│ └── RoutePassportMessageListener.kt +├── service +│ ├── RoutePassportJsonParserService.kt +│ ├── RouteGeometryConverter.kt +│ ├── RoutePassportIngestionService.kt +│ ├── RoutePassportKafkaPublisherService.kt +│ ├── RoutePassportOutboxService.kt +│ └── RoutePassportOutboxWorker.kt +├── entity +│ ├── RoutePassportEntity.kt +│ └── RoutePassportOutboxEntity.kt +├── repository +│ ├── RoutePassportRepository.kt +│ └── RoutePassportOutboxRepository.kt +├── exception +│ ├── RoutePassportContractException.kt +│ ├── RoutePassportRetryableException.kt # optional +│ └── RoutePassportFatalException.kt # optional +└── dto + └── RoutePassportOutboxPayload.kt # optional, если нужен внутренний DTO outbox payload +``` + +Правило: новые классы добавлять только если они реально отделяют ответственность. Не создавать лишние interfaces/factories/strategies. + +--- + +# 6. Детальный план реализации + +--- + +## Шаг 1. Подключить БД, JPA и Flyway к `pcp-route-processing-service` + +### 1.1. Шаг реализации + +Добавить поддержку PostgreSQL/JPA/Flyway в `pcp-route-processing-service` по аналогии с `pcp-request-service`. + +### 1.2. Вход шага + +Текущий `pcp-route-processing-service` без БД. + +Файлы: + +```text +services/pcp-route-processing-service/build.gradle.kts +services/pcp-route-processing-service/src/main/resources/application.yml +config-repo/pcp-route-processing-service.yaml +``` + +### 1.3. Выход шага + +Сервис умеет стартовать с datasource, JPA validation и Flyway migrations. + +### 1.4. Какие существующие классы участвуют + +На этом шаге классы бизнес-логики не меняются. + +### 1.5. Нужно ли вводить новую модель + +Нет. + +### 1.6. Слой + +`adapter / infrastructure` + +### 1.7. Изменения в `build.gradle.kts` + +Добавить plugin: + +```kotlin +kotlin("plugin.jpa") +``` + +Добавить dependencies: + +```kotlin +implementation("org.springframework.boot:spring-boot-starter-data-jpa") +implementation("org.springframework.boot:spring-boot-starter-flyway") +implementation("org.flywaydb:flyway-database-postgresql") +implementation("org.locationtech.jts:jts-core:1.19.0") +runtimeOnly("org.postgresql:postgresql") +``` + +`org.locationtech.jts:jts-core:1.19.0` уже используется в `pcp-request-service`, поэтому это не новая для проекта библиотека. + +### 1.8. Изменения в `config-repo/pcp-route-processing-service.yaml` + +Добавить: + +```yaml +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_route_processing + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration +``` + +### 1.9. Acceptance criteria + +1. Gradle module компилируется с JPA/Flyway dependencies. +2. При старте сервис ищет migrations в `classpath:db/migration`. +3. Hibernate не создает schema сам, а валидирует ее через `ddl-auto: validate`. + +--- + +## Шаг 2. Создать таблицу `route_passports` + +### 2.1. Шаг реализации + +Добавить Flyway migration: + +```text +services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql +``` + +### 2.2. Вход шага + +`RoutePassportDto`: + +```text +libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/routes/RoutePassportMessageDTO.kt +``` + +### 2.3. Выход шага + +Таблица `route_passports` хранит нормализованный паспорт маршрута, геометрию, параметры, metadata входного Kafka record и raw payload для аудита. + +### 2.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `RoutePassportDto` | Источник полей для таблицы. | +| `AngleRangeDto` | Источник `roll_angle_min/max`, `visir_angle_min/max`. | +| `SurveyModeStatus` | Источник `route_status`; пока всегда `PROCESSED`. | +| `ParsedRoutePassportMessage` | Источник trace/correlation metadata. | + +### 2.5. Нужно ли вводить новую модель + +Да: + +```text +RoutePassportEntity +RoutePassportRepository +``` + +### 2.6. Слой + +`adapter / storage` + +### 2.7. Рекомендуемый SQL + +```sql +CREATE TABLE IF NOT EXISTS route_passports ( + route_id UUID PRIMARY KEY, + + route_name_full TEXT NOT NULL, + route_name_short VARCHAR(255) NOT NULL, + + ka_short VARCHAR(64) NOT NULL, + ka_full VARCHAR(255) NOT NULL, + + orbit_number BIGINT NOT NULL, + orbit_state VARCHAR(64) NOT NULL, + + interval_begin TIMESTAMP NOT NULL, + interval_end TIMESTAMP NOT NULL, + + roll_angle_min NUMERIC NOT NULL, + roll_angle_max NUMERIC NOT NULL, + visir_angle_min NUMERIC NOT NULL, + visir_angle_max NUMERIC NOT NULL, + + resolution_range NUMERIC NOT NULL, + resolution_azimuth NUMERIC NOT NULL, + + polarisation JSONB NOT NULL, + processing_level VARCHAR(64) NOT NULL, + + geometry_wkt TEXT NOT NULL, + route_status VARCHAR(32) NOT NULL, + + trace_id VARCHAR(255) NULL, + correlation_id VARCHAR(255) NULL, + + source_topic VARCHAR(255) NULL, + source_partition INTEGER NULL, + source_offset BIGINT NULL, + source_key VARCHAR(255) NULL, + + raw_payload JSONB NULL, + + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_route_passports_interval + ON route_passports (interval_begin, interval_end); + +CREATE INDEX IF NOT EXISTS idx_route_passports_ka_orbit + ON route_passports (ka_short, orbit_number); + +CREATE INDEX IF NOT EXISTS idx_route_passports_status + ON route_passports (route_status); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_route_passports_source_record + ON route_passports (source_topic, source_partition, source_offset) + WHERE source_topic IS NOT NULL + AND source_partition IS NOT NULL + AND source_offset IS NOT NULL; +``` + +### 2.8. Почему `geometry_wkt TEXT`, а не PostGIS geometry + +Сейчас downstream `pcp-request-service` получает geometry как WKT string и уже сам выполняет route matching. В `pcp-route-processing-service` пока не планируются spatial queries. Поэтому PostGIS добавлять не нужно. + +Если позже появятся запросы вида “найти маршруты, пересекающие область”, тогда можно будет добавить отдельную migration с PostGIS geometry column. + +### 2.9. Почему `polarisation JSONB` + +`polarisation` — список строк. Хранить как JSONB проще, чем вводить отдельную таблицу, потому что по нему пока не видно сложных запросов/джоинов. + +В entity можно хранить это поле как `String` с `@Column(columnDefinition = "jsonb")`, чтобы не тащить дополнительные Hibernate JSON types. + +### 2.10. Acceptance criteria + +1. Таблица хранит все поля `RoutePassportDto`. +2. `route_id` является primary key и idempotency key. +3. Есть исходная Kafka metadata для аудита. +4. Есть `raw_payload`, чтобы можно было восстановить, что реально пришло во входной topic. + +--- + +## Шаг 3. Создать таблицу `route_passport_outbox` + +### 3.1. Шаг реализации + +Добавить Flyway migration: + +```text +services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql +``` + +### 3.2. Вход шага + +Текущие downstream-публикации из `RoutePassportKafkaPublisherService`: + +```text +pcp.route.in.v1 / PcpKafkaEvent.ModeStatusChangedEvent +pcp.route.georeference.v1 / PcpKafkaEvent.RouteGeoRefEvent +``` + +### 3.3. Выход шага + +Таблица outbox хранит pending Kafka-события для последующей надежной публикации. + +### 3.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `RoutePassportKafkaPublisherService` | Будет использоваться outbox worker, а не listener. | +| `KafkaTopicsProperties` | Источник имен output topics. | +| `PcpKafkaEvent` | Тип события в outbox. | +| `KafkaMessage` | Envelope payload, сохраняемый в outbox. | +| `RoutePassportDto` | Data внутри KafkaMessage. | + +### 3.5. Нужно ли вводить новую модель + +Да: + +```text +RoutePassportOutboxEntity +RoutePassportOutboxRepository +RoutePassportOutboxStatus +RoutePassportOutboxService +RoutePassportOutboxWorker +``` + +### 3.6. Слой + +`adapter / storage`, `orchestration`, `adapter / messaging` + +### 3.7. Рекомендуемый SQL + +```sql +CREATE TABLE IF NOT EXISTS route_passport_outbox ( + id UUID PRIMARY KEY, + route_id UUID NOT NULL, + + event_type VARCHAR(128) NOT NULL, + topic VARCHAR(255) NOT NULL, + message_key VARCHAR(255) NOT NULL, + + payload JSONB NOT NULL, + headers JSONB NULL, + + status VARCHAR(32) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 10, + + next_attempt_at TIMESTAMP NOT NULL, + locked_until TIMESTAMP NULL, + + last_error TEXT NULL, + + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + published_at TIMESTAMP NULL, + + CONSTRAINT fk_route_passport_outbox_route + FOREIGN KEY (route_id) + REFERENCES route_passports(route_id) + ON DELETE CASCADE, + + CONSTRAINT uk_route_passport_outbox_event + UNIQUE (route_id, event_type) +); + +CREATE INDEX IF NOT EXISTS idx_route_passport_outbox_pending + ON route_passport_outbox (status, next_attempt_at, created_at); + +CREATE INDEX IF NOT EXISTS idx_route_passport_outbox_locked + ON route_passport_outbox (locked_until); +``` + +### 3.8. Outbox statuses + +```kotlin +enum class RoutePassportOutboxStatus { + PENDING, + PUBLISHED, + FAILED +} +``` + +Не вводить `PUBLISHING` на первом этапе. Для защиты от конкурентных publisher’ов использовать `locked_until`. + +### 3.9. Почему `UNIQUE(route_id, event_type)` + +Это нужно для идемпотентности. + +Сценарий: + +```text +1. Listener сохранил route_passports + route_passport_outbox. +2. DB transaction успешно закоммитилась. +3. Сервис упал до Kafka offset ack. +4. Kafka отдаст тот же input message повторно. +5. Повторная обработка не должна создать дубль outbox-событий. +``` + +`route_id + event_type` защищает от повторного создания тех же downstream-событий. + +Ограничение: если в будущем один и тот же `route_id` может прийти как новая версия маршрута, понадобится добавить revision/version/source_event_id в idempotency key. В текущем контракте явной версии маршрута нет, поэтому `route_id` считаем business idempotency key. + +### 3.10. Acceptance criteria + +1. На один маршрут создаются максимум два outbox-события: `ModeStatusChangedEvent`, `RouteGeoRefEvent`. +2. Повторная обработка того же routeId не плодит дубли outbox rows. +3. Outbox rows можно безопасно читать несколькими instance’ами сервиса. + +--- + +## Шаг 4. Добавить JPA entity и repositories + +### 4.1. Шаг реализации + +Добавить storage-модели и repositories. + +### 4.2. Вход шага + +Миграции из шагов 2–3. + +### 4.3. Выход шага + +Код может сохранять маршрут и outbox-события в одной DB transaction. + +### 4.4. Какие существующие классы участвуют + +Прямо существующие storage-классы в route-processing-service отсутствуют. + +### 4.5. Нужно ли вводить новую модель + +Да: + +```text +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt +``` + +### 4.6. Слой + +`adapter / storage` + +### 4.7. `RoutePassportEntity` + +Поля entity должны 1-в-1 соответствовать таблице `route_passports`. + +Важные правила: + +1. `routeId` — `@Id`. +2. `version` — `@Version`, если нужна optimistic locking. +3. `createdAt` и `updatedAt` заполнять в коде, без магии БД. +4. `polarisation` и `rawPayload` можно хранить как JSON string с `columnDefinition = "jsonb"`. +5. `geometryWkt` хранить как `TEXT`. + +### 4.8. `RoutePassportOutboxEntity` + +Поля entity должны соответствовать таблице `route_passport_outbox`. + +Важные правила: + +1. `id` — generated UUID в коде. +2. `routeId` можно хранить как UUID без JPA relation, чтобы не усложнять entity graph. +3. `payload` хранить как JSON string с `columnDefinition = "jsonb"`. +4. `headers` хранить как JSON string или nullable JSON string. +5. `status` — enum string. + +Не нужно делать сложные bidirectional JPA relations. Они не нужны для этого flow. + +### 4.9. Repository methods + +`RoutePassportRepository`: + +```kotlin +interface RoutePassportRepository : JpaRepository +``` + +`RoutePassportOutboxRepository`: + +```kotlin +interface RoutePassportOutboxRepository : JpaRepository { + // native query with FOR UPDATE SKIP LOCKED for claim operation +} +``` + +Для outbox лучше сделать не один “find pending”, а сервисный метод `claimDueBatch`, который в transaction: + +1. выбирает due rows; +2. ставит `locked_until`; +3. возвращает claimed rows. + +### 4.10. Acceptance criteria + +1. Entity валидируются Hibernate against Flyway schema. +2. Нет лишних JPA relations, которые усложняют загрузку. +3. Есть repository method для claim pending outbox rows. + +--- + +## Шаг 5. Добавить `RouteGeometryConverter` и заменить ручную GeoJSON → WKT конвертацию + +### 5.1. Шаг реализации + +Вынести конвертацию geometry из `RoutePassportJsonParserService` в отдельный сервис и использовать библиотечную конвертацию. + +### 5.2. Вход шага + +Текущий код в `RoutePassportJsonParserService`: + +```kotlin +private fun RawGeoJsonGeometryDto.toWkt(): String +private fun tools.jackson.databind.JsonNode.toWktCoordinates(depth: Int): String +``` + +### 5.3. Выход шага + +Ручная рекурсивная сборка WKT удалена. Parser делегирует конвертацию в `RouteGeometryConverter`. + +### 5.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `RoutePassportJsonParserService` | Перестает собирать WKT вручную. | +| `RawGeoJsonGeometryDto` | Вход converter’а. | +| `RoutePassportDto` | Получает готовый `geometry: String`. | + +### 5.5. Нужно ли вводить новую модель + +Да: + +```text +RouteGeometryConverter +``` + +Отдельные DTO не нужны. + +### 5.6. Слой + +`algorithm` + +### 5.7. Предлагаемая ответственность converter + +```text +RouteGeometryConverter +├── принимает RawGeoJsonGeometryDto +├── проверяет supported geometry type: Polygon / MultiPolygon +├── преобразует raw geometry в GeoJSON string/object +├── читает geometry через JTS-compatible reader +├── пишет WKT через WKT writer +└── выбрасывает RoutePassportContractException при invalid geometry +``` + +### 5.8. Что считать ошибкой контракта + +| Ситуация | Исключение | Retry? | +|---|---|---| +| `geometry.type` не `Polygon` и не `MultiPolygon` | `RoutePassportContractException` | Нет | +| coordinates отсутствуют или не массив | `RoutePassportContractException` | Нет | +| geometry не парсится библиотекой | `RoutePassportContractException` | Нет | +| geometry пустая | `RoutePassportContractException` | Нет | + +### 5.9. Почему отдельный converter — не лишняя прослойка + +Это не абстракция “на будущее”. Сейчас parser смешивает две разные ответственности: + +1. разбор route passport fields; +2. геометрический алгоритм GeoJSON → WKT. + +После замены ручной конвертации на библиотеку converter будет изолировать геометрическую логику и упростит тестирование. + +### 5.10. Acceptance criteria + +1. В `RoutePassportJsonParserService` нет ручного `toWktCoordinates`. +2. `Polygon` и `MultiPolygon` продолжают поддерживаться. +3. Ошибки geometry классифицируются как non-retryable contract errors. +4. Есть unit tests на converter. + +--- + +## Шаг 6. Добавить exception taxonomy + +### 6.1. Шаг реализации + +Ввести явные exception-классы для классификации ошибок. + +### 6.2. Вход шага + +Сейчас parser использует `require(...)` и `error(...)`, что приводит к generic `IllegalArgumentException`/`IllegalStateException`. + +### 6.3. Выход шага + +Kafka error handler может различать retryable и non-retryable ошибки. + +### 6.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `RoutePassportJsonParserService` | Бросает contract exceptions вместо generic error. | +| `RouteGeometryConverter` | Бросает contract exceptions при invalid geometry. | +| `KafkaConsumerConfig` | Регистрирует non-retryable exceptions. | +| `RoutePassportMessageListener` | Не проглатывает ошибки. | + +### 6.5. Нужно ли вводить новую модель + +Да: + +```kotlin +class RoutePassportContractException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) +class RoutePassportProcessingException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) +``` + +`RoutePassportRetryableException` можно не вводить, если retryable определяются по типам инфраструктурных исключений. + +### 6.6. Слой + +`domain / algorithm / adapter` + +### 6.7. Error classification + +| Категория | Примеры | Поведение | +|---|---|---| +| `SUCCESS` | parse ok, route saved, outbox created, DB commit ok | Kafka offset ack | +| `BUSINESS/CONTRACT` | invalid JSON shape, missing required field, invalid angle, unsupported geometry | no retry или минимальный retry, потом DLQ/DLT | +| `RETRYABLE` | transient DB error, temporary DB connection issue | bounded retry, потом DLQ/DLT | +| `FATAL` | unexpected bug, incompatible serialization, unknown runtime failure | bounded retry, потом DLQ/DLT + alert | + +### 6.8. Acceptance criteria + +1. Parser/converter не используют `error(...)` для contract validation. +2. Bad input предсказуемо классифицируется как non-retryable. +3. Infrastructure failures остаются retryable. + +--- + +## Шаг 7. Добавить `RoutePassportIngestionService` + +### 7.1. Шаг реализации + +Ввести application service, который в одной DB transaction: + +1. сохраняет/обновляет `route_passports`; +2. создает две outbox-записи; +3. не публикует Kafka напрямую. + +### 7.2. Вход шага + +`ParsedRoutePassportMessage` + исходная Kafka metadata: + +```text +message value +record key +topic +partition +offset +``` + +### 7.3. Выход шага + +DB transaction committed: + +```text +route_passports row +route_passport_outbox row: ModeStatusChangedEvent +route_passport_outbox row: RouteGeoRefEvent +``` + +### 7.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `RoutePassportMessageListener` | Будет вызывать ingestion вместо publisher. | +| `RoutePassportJsonParserService` | Возвращает `ParsedRoutePassportMessage`. | +| `RoutePassportKafkaPublisherService` | Убирается из listener path. | +| `KafkaTopicsProperties` | Нужен для создания outbox rows. | +| `KafkaMessage` | Payload сохраняется в outbox. | +| `PcpKafkaEvent` | Типы outbox events. | + +### 7.5. Нужно ли вводить новую модель + +Да: + +```text +RoutePassportIngestionService +RoutePassportSourceRecordMetadata +``` + +`RoutePassportSourceRecordMetadata` может быть простой internal data class: + +```kotlin +data class RoutePassportSourceRecordMetadata( + val topic: String, + val partition: Int, + val offset: Long, + val key: String?, +) +``` + +### 7.6. Слой + +`orchestration / application` + +### 7.7. Транзакционная логика + +Псевдологика: + +```text +@Transactional +fun ingest(parsedMessage, sourceRecord, rawPayload) { + val route = parsedMessage.routePassportDto + + upsert route_passports by route.routeId + + create outbox event if absent: + routeId + ModeStatusChangedEvent + + create outbox event if absent: + routeId + RouteGeoRefEvent +} +``` + +### 7.8. Upsert route passport + +Варианты: + +#### Вариант A — простой JPA save + +```text +if route exists: + update fields +else: + insert fields +``` + +Плюс: понятно и просто. +Минус: два запроса. + +#### Вариант B — native upsert + +```sql +INSERT ... ON CONFLICT(route_id) DO UPDATE ... +``` + +Плюс: атомарнее. +Минус: больше SQL, сложнее тестировать. + +Рекомендация: начать с Варианта A. Он достаточно надежен при текущей нагрузке и проще для команды. + +### 7.9. Outbox payload generation + +Сохранять в outbox уже готовый JSON payload, а не DTO. + +Причина: outbox row должен быть самодостаточным. Если код DTO изменится после сохранения outbox row, старое pending event все равно должно быть опубликовано как было сформировано в момент ingest. + +Payload должен повторять текущий формат `RoutePassportKafkaPublisherService.buildPayload(...)`: + +```kotlin +KafkaMessage( + type = eventType, + data = parsedMessage.routePassportDto, + traceId = parsedMessage.metadata.traceId, + correlationId = parsedMessage.metadata.correlationId, + group = "pcp", +).apply { + source = "route-processing" +} +``` + +### 7.10. Kafka message key + +Пункт смены key пропущен, поэтому сохраняем текущую семантику: + +```text +message_key = parsedMessage.metadata.traceId +``` + +Это не лучший business key, но это осознанно вне scope. + +### 7.11. Acceptance criteria + +1. Listener больше не публикует Kafka напрямую. +2. При успешной обработке input message в БД появляется route row и две outbox rows. +3. Повторная обработка того же routeId не создает дублей outbox rows. +4. `route_status` сохраняется как `PROCESSED`, как и сейчас. + +--- + +## Шаг 8. Переделать `RoutePassportMessageListener` на controlled commit + +### 8.1. Шаг реализации + +Переписать listener так, чтобы он: + +1. принимал `ConsumerRecord`; +2. парсил value; +3. вызывал `RoutePassportIngestionService`; +4. подтверждал offset только после успешного завершения ingest; +5. не вызывал `RoutePassportKafkaPublisherService` напрямую. + +### 8.2. Вход шага + +Текущий listener: + +```kotlin +fun listen(message: String) +``` + +### 8.3. Выход шага + +Новый listener: + +```kotlin +fun listen(record: ConsumerRecord, acknowledgment: Acknowledgment) +``` + +### 8.4. Какие существующие классы участвуют + +| Класс | Изменение | +|---|---| +| `RoutePassportMessageListener` | Основное изменение. | +| `RoutePassportJsonParserService` | Остается. | +| `RoutePassportKafkaPublisherService` | Убирается из constructor injection listener’а. | +| `RoutePassportIngestionService` | Добавляется в constructor injection listener’а. | +| `KafkaTopicsProperties` | Остается. | + +### 8.5. Нужно ли вводить новую модель + +Да, если используется `RoutePassportSourceRecordMetadata`. + +### 8.6. Слой + +`adapter / messaging`, `orchestration` + +### 8.7. Listener behavior + +Псевдологика: + +```text +listen(record, ack): + log received topic/partition/offset/payloadSize + + parsedMessage = parser.parse(record.value()) + + ingestionService.ingest( + parsedMessage = parsedMessage, + rawPayload = record.value(), + sourceRecord = metadata(record), + ) + + ack.acknowledge() +``` + +Если parser/ingestion бросили exception: + +```text +exception bubbles to Spring Kafka DefaultErrorHandler +ack is not called +handler applies retry/DLQ policy +``` + +### 8.8. Acceptance criteria + +1. В listener нет `try/catch`, который ломает error handler, либо catch только логирует и rethrow. +2. `ack.acknowledge()` вызывается строго после успешного `ingestionService.ingest(...)`. +3. Offset не подтверждается при parse/DB ошибках. + +--- + +## Шаг 9. Настроить Kafka consumer на manual ack и disable auto commit + +### 9.1. Шаг реализации + +Изменить Kafka consumer config. + +### 9.2. Вход шага + +Текущий config: + +```yaml +spring: + kafka: + consumer: + enable-auto-commit: true +``` + +Текущий `KafkaConsumerConfig` читает `enable-auto-commit`, но container factory не задает ack mode и error handler. + +### 9.3. Выход шага + +Consumer не коммитит offset автоматически. Ack происходит вручную после DB commit. + +### 9.4. Какие существующие классы участвуют + +| Класс/файл | Изменение | +|---|---| +| `config-repo/pcp-route-processing-service.yaml` | `enable-auto-commit: false`, `listener.ack-mode`. | +| `KafkaConsumerConfig.kt` | Установить `AckMode.MANUAL_IMMEDIATE`, error handler. | + +### 9.5. Нужно ли вводить новую модель + +Нет. + +### 9.6. Слой + +`adapter / infrastructure` + +### 9.7. YAML + +```yaml +spring: + kafka: + consumer: + enable-auto-commit: false + listener: + ack-mode: manual_immediate +``` + +### 9.8. Kotlin config + +В `KafkaConsumerConfig.kafkaListenerContainerFactory()` добавить: + +```kotlin +containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE +setCommonErrorHandler(defaultErrorHandler) +``` + +### 9.9. Acceptance criteria + +1. Offset commit невозможен до явного ack. +2. При exception ack не вызывается. +3. Error handler получает ошибку и применяет retry/DLQ policy. + +--- + +## Шаг 10. Настроить bounded retry + DLQ/DLT для input topic + +### 10.1. Шаг реализации + +Добавить `DefaultErrorHandler` и `DeadLetterPublishingRecoverer` в `KafkaConsumerConfig`. + +### 10.2. Вход шага + +Ошибки из: + +```text +RoutePassportJsonParserService +RouteGeometryConverter +RoutePassportIngestionService +JPA/database layer +``` + +### 10.3. Выход шага + +Ошибочные input messages не теряются и не создают бесконечный retry. + +### 10.4. Какие существующие классы участвуют + +| Класс | Изменение | +|---|---| +| `KafkaConsumerConfig` | Добавить beans для error handler/recoverer. | +| `KafkaProducerConfig` | KafkaTemplate используется recoverer’ом для DLT publish. | +| `RoutePassportContractException` | Non-retryable exception. | +| `RoutePassportMessageListener` | Не проглатывает исключения. | + +### 10.5. Нужно ли вводить новую модель + +Да: + +```text +RoutePassportContractException +``` + +И properties: + +```text +RoutePassportDlqProperties / RoutePassportConsumerErrorHandlingProperties +``` + +Можно обойтись `@Value`, но typed properties лучше для читаемости. + +### 10.6. Слой + +`adapter / infrastructure` + +### 10.7. DLQ topic naming + +Не использовать topic вида: + +```text +pcp.request.survey-georeference.v1.dlt +``` + +Он нарушает правило, что major version должен быть в конце. + +Рекомендуемый DLQ/DLT topic: + +```text +pcp.request.survey-georeference-failed.v1 +``` + +или, если команда предпочитает явно `dlq`: + +```text +pcp.request.survey-georeference-dlq.v1 +``` + +В плане используем: + +```text +pcp.request.survey-georeference-dlq.v1 +``` + +Добавить в config: + +```yaml +app: + kafka: + topics: + input: pcp.request.survey-georeference.v1 + processed-route: pcp.route.in.v1 + route-georeference: pcp.route.georeference.v1 + input-dlq: pcp.request.survey-georeference-dlq.v1 +``` + +Соответственно расширить `KafkaTopicsProperties`: + +```kotlin +val inputDlq: String = "" +``` + +### 10.8. Retry policy + +Рекомендуемый стартовый вариант: + +```yaml +app: + route-passport: + consumer: + retry: + max-attempts: 3 + backoff-ms: 1000 + max-backoff-ms: 10000 +``` + +Поведение: + +| Exception | Retry | Destination | +|---|---:|---| +| `RoutePassportContractException` | 0 или 1 | DLQ сразу | +| JSON parse/mapping exception | 0 или 1 | DLQ сразу | +| Invalid geometry | 0 или 1 | DLQ сразу | +| DB connection/transient exceptions | bounded retry | DLQ after attempts | +| Unexpected runtime exceptions | bounded retry | DLQ after attempts | + +### 10.9. DLT payload/headers + +`DeadLetterPublishingRecoverer` должен сохранять: + +1. original payload; +2. original topic; +3. original partition; +4. original offset; +5. exception class; +6. exception message; +7. service name; +8. timestamp. + +Spring Kafka добавляет часть headers сам. Если стандартных headers недостаточно, добавить custom headers в recoverer. + +### 10.10. Observability + +Логировать: + +```text +routeId if parsed +topic/partition/offset +traceId +correlationId +exception category +DLQ topic +``` + +Не логировать полный payload. Только preview/размер. + +### 10.11. Acceptance criteria + +1. Bad contract message уходит в DLQ и не блокирует consumer group бесконечно. +2. Retry ограничен по количеству попыток. +3. DLQ topic имеет version suffix `.v1`. +4. В logs понятно, почему сообщение ушло в DLQ. + +--- + +## Шаг 11. Настроить producer reliability + +### 11.1. Шаг реализации + +Добавить надежные настройки Kafka producer. + +### 11.2. Вход шага + +Текущий `KafkaProducerConfig` задает только: + +```text +bootstrap.servers +key.serializer +value.serializer +``` + +Даже если добавить настройки в YAML, текущий custom config их не подхватит автоматически. + +### 11.3. Выход шага + +Kafka producer получает reliability properties явно. + +### 11.4. Какие существующие классы участвуют + +| Класс/файл | Изменение | +|---|---| +| `KafkaProducerConfig.kt` | Добавить producer properties в map. | +| `config-repo/pcp-route-processing-service.yaml` | Добавить `acks`, `retries`, `enable.idempotence`. | + +### 11.5. Нужно ли вводить новую модель + +Нет. + +### 11.6. Слой + +`adapter / infrastructure` + +### 11.7. YAML + +```yaml +spring: + kafka: + producer: + acks: all + retries: 10 + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 +``` + +### 11.8. Kotlin config + +Не делаем полный refactor на `KafkaProperties`, потому что это вне scope. Но текущую map нужно расширить: + +```kotlin +ProducerConfig.ACKS_CONFIG to acks +ProducerConfig.RETRIES_CONFIG to retries +ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to enableIdempotence +ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to maxInFlightRequests +``` + +### 11.9. Что это дает + +| Настройка | Что дает | +|---|---| +| `acks=all` | Broker подтверждает запись только после записи leader + in-sync replicas. | +| `retries` | Producer повторяет отправку при временных ошибках. | +| `enable.idempotence=true` | Защита от дублей, возникающих из-за producer retries. | +| `max.in.flight.requests.per.connection=5` | Совместимо с idempotent producer. | + +### 11.10. Чего это не дает + +Producer reliability не заменяет outbox. + +Outbox нужен, чтобы событие не потерялось, если: + +1. Kafka недоступна; +2. сервис упал; +3. publish прошел частично; +4. producer не успел отправить после обработки input. + +### 11.11. Acceptance criteria + +1. Outbox worker использует producer с `acks=all` и idempotence. +2. Ошибка producer publish не теряет событие, а переводит outbox row в retry/failed state. + +--- + +## Шаг 12. Переделать `RoutePassportKafkaPublisherService` под outbox + +### 12.1. Шаг реализации + +Разделить две ответственности: + +1. формирование outbox payload — в `RoutePassportIngestionService` или отдельном small helper; +2. физическая отправка Kafka record — в `RoutePassportKafkaPublisherService`. + +### 12.2. Вход шага + +Текущий `RoutePassportKafkaPublisherService.publish(parsedMessage)`. + +### 12.3. Выход шага + +Новый publisher публикует конкретную outbox row: + +```text +RoutePassportKafkaPublisherService.publish(outboxEvent) +``` + +И возвращает результат/бросает exception. + +### 12.4. Какие существующие классы участвуют + +| Класс | Изменение | +|---|---| +| `RoutePassportKafkaPublisherService` | Больше не строит два события из `ParsedRoutePassportMessage`. Публикует одну outbox row. | +| `RoutePassportIngestionService` | Создает две outbox rows. | +| `RoutePassportOutboxWorker` | Вызывает publisher. | + +### 12.5. Нужно ли вводить новую модель + +Можно использовать `RoutePassportOutboxEntity` как вход publisher’а. + +Если не хочется давать service слой доступ к JPA entity, можно добавить internal DTO: + +```kotlin +data class RoutePassportOutboxMessage( + val id: UUID, + val topic: String, + val messageKey: String, + val eventType: String, + val payload: String, + val headers: Map, +) +``` + +Но для первого варианта допустимо использовать entity, чтобы не плодить лишние классы. + +### 12.6. Слой + +`adapter / messaging` + +### 12.7. Publish behavior + +Важно: publisher должен дождаться результата отправки. + +Псевдологика: + +```text +record = ProducerRecord(topic, messageKey, payload) +record.headers.add("type", eventType) +future = kafkaTemplate.send(record) +future.get(timeout) +``` + +Если publish упал — бросить exception. Не просто логировать. + +### 12.8. Acceptance criteria + +1. Ошибка Kafka publish видна outbox worker’у как exception. +2. Publisher не знает ничего про два downstream topic. Он публикует одну outbox row. +3. Заголовок `type` сохраняется как сейчас. + +--- + +## Шаг 13. Добавить `RoutePassportOutboxWorker` + +### 13.1. Шаг реализации + +Добавить scheduled worker, который публикует pending outbox events. + +### 13.2. Вход шага + +Rows в `route_passport_outbox` со статусом `PENDING` и `next_attempt_at <= now()`. + +### 13.3. Выход шага + +Rows становятся: + +```text +PUBLISHED — если Kafka publish успешен +PENDING — если ошибка retryable и attempts < max_attempts +FAILED — если attempts >= max_attempts +``` + +### 13.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `RoutePassportKafkaPublisherService` | Физически отправляет Kafka record. | +| `RoutePassportOutboxRepository` | Читает/обновляет outbox rows. | +| `KafkaTopicsProperties` | Не нужен worker’у напрямую, topic уже записан в outbox. | + +### 13.5. Нужно ли вводить новую модель + +Да: + +```text +RoutePassportOutboxWorker +RoutePassportOutboxService +RoutePassportOutboxProperties +``` + +### 13.6. Слой + +`orchestration / adapter` + +### 13.7. Properties + +```yaml +app: + route-passport: + outbox: + enabled: true + batch-size: 50 + fixed-delay-ms: 1000 + lock-timeout-ms: 60000 + publish-timeout-ms: 10000 + max-attempts: 10 + initial-backoff-ms: 1000 + max-backoff-ms: 60000 +``` + +### 13.8. Worker algorithm + +```text +on schedule: + claimedEvents = outboxService.claimDueBatch(batchSize) + + for event in claimedEvents: + try: + publisher.publish(event) + outboxService.markPublished(event.id) + catch exception: + outboxService.markRetryOrFailed(event.id, exception) +``` + +### 13.9. Claim algorithm + +В отдельной transaction: + +```text +1. select due rows with FOR UPDATE SKIP LOCKED +2. set locked_until = now + lockTimeout +3. update updated_at +4. return rows +``` + +Native query example: + +```sql +SELECT * +FROM route_passport_outbox +WHERE status = 'PENDING' + AND next_attempt_at <= now() + AND (locked_until IS NULL OR locked_until < now()) +ORDER BY created_at +LIMIT :limit +FOR UPDATE SKIP LOCKED; +``` + +После select обновить `locked_until` у выбранных rows. + +### 13.10. Backoff formula + +Простая exponential backoff с cap: + +```text +nextDelay = min(initialBackoffMs * 2^(attempts - 1), maxBackoffMs) +``` + +Для первого варианта можно без jitter. Если будут синхронные retry spikes — добавить jitter позже. + +### 13.11. At-least-once behavior + +Outbox worker дает at-least-once delivery. + +Возможен дубль в сценарии: + +```text +1. Kafka publish succeeded. +2. Service crashed before marking row as PUBLISHED. +3. locked_until expired. +4. Worker retries same event. +``` + +Это нормально для outbox. Downstream consumers обязаны быть идемпотентными. + +Текущий downstream status: + +| Consumer | Идемпотентность | +|---|---| +| `pcp-request-service` / `RouteMatchingService` | Частично есть: `request_route_matches` unique `(request_id, route_id)` защищает от дублей matching. | +| `pcp-mission-planing-service` | Требует отдельной проверки: повторный `ModeStatusChangedEvent` может повторно обновить режим и породить побочные события. | + +### 13.12. Acceptance criteria + +1. Pending outbox rows публикуются в Kafka. +2. Ошибки publish не теряют rows. +3. После max attempts row становится `FAILED`. +4. Несколько instances сервиса не публикуют один row одновременно. + +--- + +## Шаг 14. Настроить storage и outbox observability + +### 14.1. Шаг реализации + +Добавить логи и базовые метрики для ingestion/outbox. + +### 14.2. Вход шага + +Новые сервисы: + +```text +RoutePassportIngestionService +RoutePassportOutboxWorker +RoutePassportKafkaPublisherService +``` + +### 14.3. Выход шага + +По logs/metrics можно понять: + +1. сколько маршрутов принято; +2. сколько outbox events pending; +3. сколько published; +4. сколько failed; +5. какие ошибки уходят в DLQ. + +### 14.4. Какие существующие классы участвуют + +| Класс | Изменение | +|---|---| +| `RoutePassportMessageListener` | Логи input topic/partition/offset. | +| `RoutePassportIngestionService` | Логи routeId, status, created/updated, outbox inserted/skipped. | +| `RoutePassportOutboxWorker` | Логи batch size, publish success/failure. | +| `RoutePassportKafkaPublisherService` | Логи topic/partition/offset после publish. | + +### 14.5. Нужно ли вводить новую модель + +Нет. + +### 14.6. Слой + +`observability` + +### 14.7. Рекомендуемые log fields + +```text +routeId +routeNameFull +traceId +correlationId +sourceTopic +sourcePartition +sourceOffset +outboxEventId +eventType +targetTopic +attempts +status +``` + +### 14.8. Метрики + +Если в проекте уже есть Micrometer через Actuator, добавить counters/gauges: + +```text +route_passport_ingest_success_total +route_passport_ingest_failure_total +route_passport_outbox_published_total +route_passport_outbox_failed_total +route_passport_outbox_retry_total +route_passport_outbox_pending_count +route_passport_outbox_failed_count +``` + +Gauge `pending_count` и `failed_count` можно добавить позже, если не хочется усложнять первый MR. Counters на publish/retry/fail — минимально полезны. + +### 14.9. Acceptance criteria + +1. В логах можно отследить путь routeId от input до outbox publish. +2. Failed outbox rows не теряются незаметно. +3. DLQ события видны в логах с причиной. + +--- + +## Шаг 15. Обновить конфигурацию topic properties + +### 15.1. Шаг реализации + +Расширить `KafkaTopicsProperties` новым DLQ topic. + +### 15.2. Вход шага + +Текущий класс: + +```kotlin +@ConfigurationProperties(prefix = "app.kafka.topics") +data class KafkaTopicsProperties( + val input: String = "", + val processedRoute: String = "", + val routeGeoreference: String = "", +) +``` + +### 15.3. Выход шага + +Добавить: + +```kotlin +val inputDlq: String = "" +``` + +### 15.4. Какие существующие классы участвуют + +| Класс | Изменение | +|---|---| +| `KafkaTopicsProperties` | Добавить `inputDlq`. | +| `KafkaConsumerConfig` | Использовать `inputDlq` в `DeadLetterPublishingRecoverer`. | +| `config-repo/pcp-route-processing-service.yaml` | Добавить topic name. | + +### 15.5. Нужно ли вводить новую модель + +Нет. + +### 15.6. Слой + +`adapter / config` + +### 15.7. Acceptance criteria + +1. Сервис не стартует без DLQ topic name. +2. DLQ topic задается централизованно через config server. + +--- + +## Шаг 16. Минимальные тесты + +### 16.1. Шаг реализации + +Добавить/обновить тесты только под измененную логику. + +### 16.2. Вход шага + +Текущие тесты: + +```text +RoutePassportJsonParserServiceTest.kt +RoutePassportKafkaPublisherServiceTest.kt +RoutePassportMessageListenerTest.kt +RouteProcessingTestControllerTest.kt +``` + +### 16.3. Выход шага + +Тесты покрывают: + +1. parser; +2. geometry converter; +3. ingestion service; +4. outbox worker; +5. listener ack/error behavior; +6. error classification. + +### 16.4. Какие существующие классы участвуют + +| Тест | Что изменить | +|---|---| +| `RoutePassportJsonParserServiceTest` | Убрать ожидания ручной WKT конвертации, использовать `RouteGeometryConverter`. | +| `RoutePassportKafkaPublisherServiceTest` | Проверять publish одной outbox row и exception on failure. | +| `RoutePassportMessageListenerTest` | Проверять, что listener вызывает ingestion и ack после успеха; не ack при exception. | + +### 16.5. Нужно ли вводить новую модель + +Только test fixtures/builders, если они упрощают тесты. + +### 16.6. Слой + +`tests` + +### 16.7. Unit tests + +#### `RouteGeometryConverterTest` + +Проверки: + +| Case | Ожидание | +|---|---| +| valid Polygon | возвращается WKT `POLYGON ...` | +| valid MultiPolygon | возвращается WKT `MULTIPOLYGON ...` | +| unsupported geometry type | `RoutePassportContractException` | +| invalid coordinates | `RoutePassportContractException` | +| empty geometry | `RoutePassportContractException` | + +#### `RoutePassportJsonParserServiceTest` + +Проверки: + +| Case | Ожидание | +|---|---| +| valid envelope | возвращается `ParsedRoutePassportMessage` | +| missing routeId | routeId генерируется из `id`, как сейчас | +| rollAngle list | min/max вычисляются корректно | +| visirAngle list | min/max вычисляются корректно | +| invalid interval | contract exception | +| routeStatus | остается default `PROCESSED` | + +#### `RoutePassportIngestionServiceTest` + +Проверки: + +| Case | Ожидание | +|---|---| +| first route ingest | created route + 2 outbox rows | +| duplicate same routeId | no duplicate outbox rows | +| updated same routeId | route row updated, outbox unique preserved | +| raw payload | saved to `raw_payload` | +| source metadata | topic/partition/offset saved | + +#### `RoutePassportOutboxWorkerTest` + +Проверки: + +| Case | Ожидание | +|---|---| +| pending event publish success | status `PUBLISHED`, `published_at` set | +| publish failure attempts < max | status `PENDING`, attempts incremented, `next_attempt_at` moved | +| publish failure attempts >= max | status `FAILED`, `last_error` set | +| locked row | not claimed until lock expires | + +#### `RoutePassportMessageListenerTest` + +Проверки: + +| Case | Ожидание | +|---|---| +| success | parser + ingestion called, ack called | +| parser exception | ack not called, exception rethrown | +| ingestion exception | ack not called, exception rethrown | + +### 16.8. Integration tests + +Минимально желательно добавить JPA test для repositories и migration validation. + +Варианты: + +1. если Testcontainers уже используется в проекте — использовать PostgreSQL Testcontainer; +2. если нет — на первом этапе ограничиться `@DataJpaTest` + embedded database нельзя, потому что используются JSONB и PostgreSQL-specific SQL; +3. pragmatic вариант — unit tests + локальный manual check через real PostgreSQL в dev environment. + +Рекомендация: добавить Testcontainers PostgreSQL, если это приемлемо для проекта. Если нет — явно прописать manual verification checklist. + +### 16.9. Acceptance criteria + +1. Измененная логика покрыта unit tests. +2. Outbox retry/fail behavior проверен. +3. Listener ack behavior проверен. +4. Parser сохраняет прежнюю бизнес-семантику routeStatus = PROCESSED. + +--- + +## Шаг 17. Обновить runtime configuration + +### 17.1. Шаг реализации + +Свести все новые настройки в config server file. + +### 17.2. Вход шага + +```text +config-repo/pcp-route-processing-service.yaml +``` + +### 17.3. Выход шага + +Один конфиг содержит: + +1. datasource; +2. flyway; +3. kafka consumer manual ack settings; +4. producer reliability; +5. DLQ topic; +6. outbox properties. + +### 17.4. Какие существующие классы участвуют + +| Класс | Как участвует | +|---|---| +| `KafkaTopicsProperties` | topic settings. | +| `RoutePassportOutboxProperties` | outbox worker settings. | +| `KafkaConsumerConfig` | retry/ack settings. | +| `KafkaProducerConfig` | producer reliability settings. | + +### 17.5. Нужно ли вводить новую модель + +Да, желательно: + +```kotlin +@ConfigurationProperties(prefix = "app.route-passport.outbox") +data class RoutePassportOutboxProperties(...) +``` + +Для retry тоже можно: + +```kotlin +@ConfigurationProperties(prefix = "app.route-passport.consumer.retry") +data class RoutePassportConsumerRetryProperties(...) +``` + +### 17.6. Слой + +`adapter / config` + +### 17.7. Итоговый YAML fragment + +```yaml +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_route_processing + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + kafka: + bootstrap-servers: ${pcp.infra.kafka.host}:${pcp.infra.kafka.port} + consumer: + group-id: pcp-route-processing-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + enable-auto-commit: false + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + acks: all + retries: 10 + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 + listener: + missing-topics-fatal: false + ack-mode: manual_immediate + +app: + kafka: + topics: + input: pcp.request.survey-georeference.v1 + input-dlq: pcp.request.survey-georeference-dlq.v1 + processed-route: pcp.route.in.v1 + route-georeference: pcp.route.georeference.v1 + route-passport: + consumer: + retry: + max-attempts: 3 + backoff-ms: 1000 + max-backoff-ms: 10000 + outbox: + enabled: true + batch-size: 50 + fixed-delay-ms: 1000 + lock-timeout-ms: 60000 + publish-timeout-ms: 10000 + max-attempts: 10 + initial-backoff-ms: 1000 + max-backoff-ms: 60000 +``` + +### 17.8. Acceptance criteria + +1. Все новые параметры находятся в config-repo. +2. Defaults безопасны для dev/local. +3. Production может менять retry/outbox параметры без пересборки сервиса. + +--- + +# 7. Итоговый список новых файлов + +## 7.1. Migrations + +```text +services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql +services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql +``` + +## 7.2. Entities + +```text +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt +``` + +## 7.3. Repositories + +```text +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt +``` + +## 7.4. Services + +```text +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverter.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxWorker.kt +``` + +## 7.5. Exceptions + +```text +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportContractException.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportProcessingException.kt +``` + +`RoutePassportProcessingException` optional. Добавлять только если реально нужен для отделения unexpected processing failures от generic exceptions. + +## 7.6. Config properties + +```text +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportOutboxProperties.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportConsumerRetryProperties.kt +``` + +--- + +# 8. Итоговый список изменяемых файлов + +```text +services/pcp-route-processing-service/build.gradle.kts +config-repo/pcp-route-processing-service.yaml +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt +services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt +services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListenerTest.kt +services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserServiceTest.kt +services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherServiceTest.kt +``` + +--- + +# 9. Порядок реализации по MR/commit-ам + +## MR 1. DB foundation + +Состав: + +1. build.gradle: JPA/Flyway/PostgreSQL/JTS dependencies; +2. datasource/flyway config; +3. migrations `route_passports`, `route_passport_outbox`; +4. entities/repositories; +5. basic repository tests или compile validation. + +Риск: низкий/средний. Код обработки Kafka еще можно не переключать. + +--- + +## MR 2. Parser cleanup and geometry converter + +Состав: + +1. `RouteGeometryConverter`; +2. убрать ручной WKT converter из parser; +3. добавить `RoutePassportContractException`; +4. обновить parser tests; +5. добавить converter tests. + +Риск: средний, потому что меняется geometry output. Нужно проверить WKT formatting compatibility с `pcp-request-service`. + +--- + +## MR 3. Ingestion service + outbox writing + +Состав: + +1. `RoutePassportIngestionService`; +2. создание route row; +3. создание двух outbox rows; +4. idempotency by `route_id + event_type`; +5. tests на duplicate processing. + +Риск: средний. Kafka listener еще можно временно не переключать, если сделать service isolated. + +--- + +## MR 4. Listener controlled commit + DLQ + +Состав: + +1. listener принимает `ConsumerRecord` + `Acknowledgment`; +2. listener вызывает ingestion; +3. manual ack после успешного ingest; +4. `enable-auto-commit=false`; +5. `DefaultErrorHandler` + `DeadLetterPublishingRecoverer`; +6. DLQ topic config; +7. tests на ack/not ack. + +Риск: высокий, потому что меняется delivery semantics входного Kafka topic. + +--- + +## MR 5. Outbox publisher worker + +Состав: + +1. `RoutePassportOutboxService`; +2. `RoutePassportOutboxWorker`; +3. publish one outbox row; +4. mark published/retry/failed; +5. producer reliability config; +6. tests на success/retry/failed. + +Риск: высокий, потому что это основной механизм downstream publication. + +--- + +## MR 6. Observability and hardening + +Состав: + +1. structured logs; +2. metrics/counters; +3. failed outbox visibility; +4. manual replay notes/runbook draft; +5. финальный cleanup. + +Риск: низкий. + +--- + +# 10. Поведение после внедрения + +## 10.1. Happy path + +```text +1. Kafka input message received. +2. Parser reads envelope and creates RoutePassportDto. +3. Geometry converter converts GeoJSON geometry to WKT. +4. Ingestion service stores/updates route_passports. +5. Ingestion service creates outbox events: + - ModeStatusChangedEvent + - RouteGeoRefEvent +6. DB transaction commits. +7. Listener acknowledges Kafka offset. +8. Outbox worker publishes events. +9. Outbox rows become PUBLISHED. +``` + +## 10.2. Bad input + +```text +1. Kafka input message received. +2. Parser/converter detects contract error. +3. Listener throws RoutePassportContractException. +4. Error handler sends original input to DLQ. +5. Offset is committed after recoverer succeeds. +6. No route_passports row is created. +7. No outbox rows are created. +``` + +## 10.3. DB down during ingest + +```text +1. Kafka input message received. +2. Parser succeeds. +3. Ingestion fails due to DB error. +4. Listener throws exception. +5. Error handler retries with bounded backoff. +6. If DB recovers, ingest succeeds and offset is acked. +7. If DB does not recover after max attempts, message goes to DLQ. +``` + +## 10.4. Kafka output down during outbox publish + +```text +1. Input message already safely stored in DB. +2. Outbox worker tries to publish. +3. Kafka publish fails. +4. Outbox row remains retryable. +5. next_attempt_at is moved by backoff. +6. Worker retries later. +7. After max attempts row becomes FAILED. +``` + +--- + +# 11. Production readiness checklist + +## 11.1. Correctness + +- [ ] `route_passports` stores all normalized route passport fields. +- [ ] `route_status` remains `PROCESSED` intentionally. +- [ ] `route_id` is primary idempotency key. +- [ ] Duplicate input message does not create duplicate outbox events. +- [ ] Geometry conversion supports Polygon and MultiPolygon. +- [ ] Invalid geometry goes to DLQ, not infinite retry. + +## 11.2. Reliability + +- [ ] `enable-auto-commit=false`. +- [ ] Manual ack happens only after DB commit. +- [ ] Listener does not publish Kafka output directly. +- [ ] Outbox worker retries failed publishes. +- [ ] Outbox worker marks permanently failed events. +- [ ] Kafka producer uses `acks=all`. +- [ ] Kafka producer idempotence is enabled. +- [ ] DLQ topic configured. +- [ ] Retry is bounded. + +## 11.3. Observability + +- [ ] Logs include `routeId`, `traceId`, `correlationId`, source topic/partition/offset. +- [ ] Outbox failed events are visible. +- [ ] DLQ sends are logged with exception category. +- [ ] Metrics exist for ingest success/failure and outbox publish/fail/retry. + +## 11.4. Operational + +- [ ] DB `pcp_route_processing` exists in target environment. +- [ ] Flyway migrations run successfully. +- [ ] DLQ topic exists or auto-created by Kafka infra policy. +- [ ] Output topics exist: + - [ ] `pcp.route.in.v1` + - [ ] `pcp.route.georeference.v1` +- [ ] Runbook exists for failed outbox rows. +- [ ] Runbook exists for DLQ replay. + +--- + +# 12. Runbook drafts + +## 12.1. Проверить зависшие outbox events + +```sql +SELECT status, COUNT(*) +FROM route_passport_outbox +GROUP BY status; +``` + +```sql +SELECT id, route_id, event_type, topic, attempts, next_attempt_at, locked_until, last_error +FROM route_passport_outbox +WHERE status IN ('PENDING', 'FAILED') +ORDER BY updated_at DESC +LIMIT 100; +``` + +## 12.2. Переотправить FAILED outbox event вручную + +Только после проверки причины ошибки. + +```sql +UPDATE route_passport_outbox +SET status = 'PENDING', + attempts = 0, + next_attempt_at = now(), + locked_until = NULL, + last_error = NULL, + updated_at = now() +WHERE id = '' + AND status = 'FAILED'; +``` + +## 12.3. Найти route passport по routeId + +```sql +SELECT * +FROM route_passports +WHERE route_id = ''; +``` + +## 12.4. Проверить события по routeId + +```sql +SELECT id, event_type, topic, status, attempts, published_at, last_error +FROM route_passport_outbox +WHERE route_id = '' +ORDER BY created_at; +``` + +--- + +# 13. Риски, которые останутся после выполнения плана + +| Риск | Почему остается | Что делать позже | +|---|---|---| +| Kafka key остается `traceId` | Пункт смены key исключен из scope. | Отдельно решить: `routeId` или `correlationId` как business key. | +| Нет fallback trace/correlation | Пункт исключен из scope. | Добавить генерацию fallback и явно логировать missing metadata. | +| `routeStatus` всегда `PROCESSED` | Осознанное решение оставить как есть. | Когда появится контракт статусов, читать статус из input и валидировать enum. | +| Test REST controller остается в main-коде | Пункт исключен из scope. | Удалить или закрыть через `@Profile("local", "dev")`. | +| README/fixtures могут быть устаревшими | Пункт исключен из scope. | Обновить документацию контракта route passport. | +| Downstream idempotency не полностью проверена | План меняет producer delivery на at-least-once. | Проверить `pcp-mission-planing-service` на повторный `ModeStatusChangedEvent`. | +| Нет Kafka transactions | Выбран outbox вместо Kafka transactions. | Это нормально, если downstream consumers идемпотентны. | + +--- + +# 14. Критерии готовности всей задачи + +Задачу можно считать выполненной, если: + +1. `pcp-route-processing-service` имеет собственную БД `pcp_route_processing`. +2. Принятый route passport сохраняется в `route_passports`. +3. Downstream-события создаются в `route_passport_outbox`, а не публикуются напрямую из listener. +4. Kafka offset input message подтверждается только после успешного DB commit. +5. Outbox worker публикует pending events в: + - `pcp.route.in.v1`; + - `pcp.route.georeference.v1`. +6. Ошибки publish не теряют события: outbox row остается retryable или становится `FAILED` после max attempts. +7. Bad input уходит в DLQ/DLT topic. +8. Retry bounded, бесконечного retry нет. +9. GeoJSON → WKT конвертация выполняется библиотечно. +10. `routeStatus` остается `PROCESSED`, и это явно зафиксировано в коде/тестах как текущая бизнес-семантика. +11. Минимальные тесты покрывают parser, converter, ingestion, outbox worker, listener ack/error behavior. + +--- + +# 15. Короткая итоговая формулировка для задачи в трекере + +```md +Доработать `pcp-route-processing-service` route-passport flow до production-ready обработки: + +- добавить service-owned PostgreSQL DB `pcp_route_processing`; +- сохранять принятые route passports в `route_passports`; +- реализовать DB outbox `route_passport_outbox` для downstream Kafka events; +- убрать прямую публикацию Kafka events из input listener; +- подтверждать input Kafka offset только после DB commit; +- добавить bounded retry + DLQ для input topic; +- добавить outbox worker для публикации `ModeStatusChangedEvent` и `RouteGeoRefEvent`; +- настроить Kafka producer reliability (`acks=all`, retries, idempotence); +- заменить ручной GeoJSON → WKT converter на библиотечный; +- оставить текущую семантику `routeStatus = PROCESSED`; +- не добавлять BPM/Camunda, flow остается Kafka-only. +``` diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/RoutePassportConsumerApplication.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/RoutePassportConsumerApplication.kt new file mode 100644 index 0000000..09ee37b --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/RoutePassportConsumerApplication.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.routepassportconsumer + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.kafka.annotation.EnableKafka + +/** + * Главный класс Spring Boot приложения. + * + * Приложение поднимает только инфраструктуру потребителя Kafka и сервис парсинга JSON. + */ +@ConfigurationPropertiesScan +@SpringBootApplication +@EnableKafka +class RoutePassportConsumerApplication + +/** + * Точка входа приложения. + * + * @param args аргументы командной строки, которые передаются в Spring Boot. + */ +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt new file mode 100644 index 0000000..2d2217a --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfig.kt @@ -0,0 +1,131 @@ +package space.nstart.pcp.routepassportconsumer.config + +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonMappingException +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.header.Headers +import org.apache.kafka.common.header.internals.RecordHeader +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.ContainerProperties +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.util.backoff.ExponentialBackOff +import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException +import tools.jackson.core.JacksonException +import java.nio.charset.StandardCharsets + +@Configuration +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +class KafkaConsumerConfig( + @param:Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String, + @param:Value("\${spring.kafka.consumer.group-id}") + private val groupId: String, + @param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}") + private val autoOffsetReset: String, +) { + + @Bean + fun consumerFactory(): ConsumerFactory = + DefaultKafkaConsumerFactory( + mutableMapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ), + ) + + @Bean + fun kafkaListenerContainerFactory( + kafkaErrorHandler: DefaultErrorHandler, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + setConsumerFactory(consumerFactory()) + setCommonErrorHandler(kafkaErrorHandler) + containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE + } + + @Bean + fun deadLetterPublishingRecoverer( + kafkaTemplate: KafkaTemplate, + kafkaTopicsProperties: KafkaTopicsProperties, + ): DeadLetterPublishingRecoverer = + DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ -> + TopicPartition(kafkaTopicsProperties.inputDlq, record.partition()) + }.apply { + addHeadersFunction { record, exception -> routePassportDlqHeaders(record, exception) } + } + + @Bean + fun kafkaErrorHandler( + deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer, + retryProperties: RoutePassportConsumerRetryProperties, + ): DefaultErrorHandler = + DefaultErrorHandler( + deadLetterPublishingRecoverer, + routePassportConsumerBackOff(retryProperties), + ).apply { + addNotRetryableExceptions( + RoutePassportContractException::class.java, + JacksonException::class.java, + JsonProcessingException::class.java, + JsonMappingException::class.java, + JsonParseException::class.java, + ) + setCommitRecovered(true) + } + + private fun routePassportConsumerBackOff( + retryProperties: RoutePassportConsumerRetryProperties, + ): ExponentialBackOff = + ExponentialBackOff().apply { + initialInterval = retryProperties.backoffMs + multiplier = RETRY_BACKOFF_MULTIPLIER + maxInterval = retryProperties.maxBackoffMs + maxAttempts = (retryProperties.maxAttempts - 1).coerceAtLeast(0) + } + + private fun routePassportDlqHeaders( + record: ConsumerRecord<*, *>, + exception: Exception, + ): Headers = + RecordHeaders().apply { + addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME) + addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name) + addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty()) + addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic()) + addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString()) + addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString()) + } + + private fun Headers.addStringHeader(name: String, value: String) { + add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8))) + } + + private companion object { + const val SERVICE_NAME = "pcp-route-processing-service" + const val RETRY_BACKOFF_MULTIPLIER = 2.0 + + const val HEADER_SERVICE_NAME = "pcp-service-name" + const val HEADER_EXCEPTION_CLASS = "pcp-exception-class" + const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message" + const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic" + const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition" + const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset" + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt new file mode 100644 index 0000000..4995630 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaProducerConfig.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp.routepassportconsumer.config + +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory + +/** + * Явная producer-конфигурация для route-processing-service. + * + * В этом модуле Kafka producer bean'ы не поднимались автоконфигурацией, + * поэтому template регистрируется явно. + */ +@Configuration +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +class KafkaProducerConfig( + @param:Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String, +) { + + @Bean + fun producerFactory(): ProducerFactory = + DefaultKafkaProducerFactory( + mutableMapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ), + ) + + @Bean + fun kafkaTemplate(): KafkaTemplate = KafkaTemplate(producerFactory()) +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt new file mode 100644 index 0000000..1e5faf7 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaTopicsProperties.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp.routepassportconsumer.config + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +/** + * Типизированные Kafka topic-настройки сервиса. + * + * Все значения приходят из spring-cloud-config-server и валидируются при старте, + * чтобы сервис не запускался без обязательных имен входного и выходных topic'ов. + */ +@Validated +@ConfigurationProperties(prefix = "app.kafka.topics") +data class KafkaTopicsProperties( + + /** Topic, из которого сервис читает входящие паспорта маршрутов. */ + @field:NotBlank + val input: String = "", + + /** DLQ topic для входящих паспортов маршрутов, которые не удалось обработать. */ + @field:NotBlank + val inputDlq: String = "", + + /** Topic, в который сервис будет публиковать сообщение об обработанном маршруте. */ + @field:NotBlank + val processedRoute: String = "", + + /** Topic, в который сервис будет публиковать сообщение геопривязки маршрута. */ + @field:NotBlank + val routeGeoreference: String = "", +) diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/OpenApiConfig.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/OpenApiConfig.kt new file mode 100644 index 0000000..5b782e9 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/OpenApiConfig.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.routepassportconsumer.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * Базовая OpenAPI-конфигурация для route-processing-service. + */ +@Configuration +class OpenApiConfig { + @Bean + fun routeProcessingOpenApi(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("PCP Route Processing Service API") + .description("REST API сервиса обработки паспортов маршрутов PCP.") + .version("v1"), + ) + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportConsumerRetryProperties.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportConsumerRetryProperties.kt new file mode 100644 index 0000000..d0c3438 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/config/RoutePassportConsumerRetryProperties.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.routepassportconsumer.config + +import jakarta.validation.constraints.Min +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +/** + * Настройки bounded retry для input Kafka consumer route passport flow. + */ +@Validated +@ConfigurationProperties(prefix = "app.route-passport.consumer.retry") +data class RoutePassportConsumerRetryProperties( + + /** Максимальное количество попыток обработки, включая первичную delivery. */ + @field:Min(1) + val maxAttempts: Long = 3, + + /** Начальная задержка retry в миллисекундах. */ + @field:Min(0) + val backoffMs: Long = 1_000, + + /** Максимальная задержка между retry в миллисекундах. */ + @field:Min(0) + val maxBackoffMs: Long = 30_000, +) diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/controller/RouteProcessingTestController.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/controller/RouteProcessingTestController.kt new file mode 100644 index 0000000..278ffac --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/controller/RouteProcessingTestController.kt @@ -0,0 +1,45 @@ +package space.nstart.pcp.routepassportconsumer.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.core.io.ClassPathResource +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import java.nio.charset.StandardCharsets + +@RestController +@RequestMapping("/api/route-processing") +@Tag(name = "Route Processing", description = "Тестовые endpoint'ы сервиса обработки маршрутов") +class RouteProcessingTestController( + private val kafkaTemplate: KafkaTemplate, + private val kafkaTopicsProperties: KafkaTopicsProperties, +) { + + @GetMapping("/test") + @Operation(summary = "Проверка доступности сервиса") + fun test(): Map = mapOf( + "status" to "ok", + "service" to "pcp-route-processing-service", + ) + + @PostMapping("/test/send-passport-message") + @Operation(summary = "Отправить тестовый паспорт маршрута в Kafka") + fun sendPassportMessage(): Map { + val payload = ClassPathResource("test-passport-message.json") + .inputStream + .readAllBytes() + .toString(StandardCharsets.UTF_8) + + kafkaTemplate.send(kafkaTopicsProperties.input, payload) + + return mapOf( + "status" to "sent", + "topic" to kafkaTopicsProperties.input, + "resource" to "classpath:test-passport-message.json", + ) + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt new file mode 100644 index 0000000..47b5a79 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportEntity.kt @@ -0,0 +1,108 @@ +package space.nstart.pcp.routepassportconsumer.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.Version +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import tools.jackson.databind.JsonNode +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "route_passports") +class RoutePassportEntity( + @Id + @Column(name = "route_id", nullable = false) + var routeId: UUID, + + @Column(name = "route_name_full", nullable = false, columnDefinition = "TEXT") + var routeNameFull: String, + + @Column(name = "route_name_short", nullable = false, length = 255) + var routeNameShort: String, + + @Column(name = "ka_short", nullable = false, length = 64) + var kaShort: String, + + @Column(name = "ka_full", nullable = false, length = 255) + var kaFull: String, + + @Column(name = "orbit_number", nullable = false) + var orbitNumber: Long, + + @Column(name = "orbit_state", nullable = false, length = 64) + var orbitState: String, + + @Column(name = "interval_begin", nullable = false) + var intervalBegin: LocalDateTime, + + @Column(name = "interval_end", nullable = false) + var intervalEnd: LocalDateTime, + + @Column(name = "roll_angle_min", nullable = false) + var rollAngleMin: BigDecimal, + + @Column(name = "roll_angle_max", nullable = false) + var rollAngleMax: BigDecimal, + + @Column(name = "visir_angle_min", nullable = false) + var visirAngleMin: BigDecimal, + + @Column(name = "visir_angle_max", nullable = false) + var visirAngleMax: BigDecimal, + + @Column(name = "resolution_range", nullable = false) + var resolutionRange: BigDecimal, + + @Column(name = "resolution_azimuth", nullable = false) + var resolutionAzimuth: BigDecimal, + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "polarisation", nullable = false, columnDefinition = "jsonb") + var polarisation: JsonNode, + + @Column(name = "processing_level", nullable = false, length = 64) + var processingLevel: String, + + @Column(name = "geometry_wkt", nullable = false, columnDefinition = "TEXT") + var geometryWkt: String, + + @Column(name = "route_status", nullable = false, length = 32) + var routeStatus: String, + + @Column(name = "trace_id", length = 255) + var traceId: String? = null, + + @Column(name = "correlation_id", length = 255) + var correlationId: String? = null, + + @Column(name = "source_topic", length = 255) + var sourceTopic: String? = null, + + @Column(name = "source_partition") + var sourcePartition: Int? = null, + + @Column(name = "source_offset") + var sourceOffset: Long? = null, + + @Column(name = "source_key", length = 255) + var sourceKey: String? = null, + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "raw_payload", columnDefinition = "jsonb") + var rawPayload: JsonNode? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime, + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime, + + @Version + @Column(name = "version", nullable = false) + var version: Long = 0, +) diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt new file mode 100644 index 0000000..e071be1 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxEntity.kt @@ -0,0 +1,69 @@ +package space.nstart.pcp.routepassportconsumer.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import tools.jackson.databind.JsonNode +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "route_passport_outbox") +class RoutePassportOutboxEntity( + @Id + @Column(name = "id", nullable = false) + var id: UUID, + + @Column(name = "route_id", nullable = false) + var routeId: UUID, + + @Column(name = "event_type", nullable = false, length = 128) + var eventType: String, + + @Column(name = "topic", nullable = false, length = 255) + var topic: String, + + @Column(name = "message_key", nullable = false, length = 255) + var messageKey: String, + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload", nullable = false, columnDefinition = "jsonb") + var payload: JsonNode, + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "headers", columnDefinition = "jsonb") + var headers: JsonNode? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + var status: RoutePassportOutboxStatus, + + @Column(name = "attempts", nullable = false) + var attempts: Int, + + @Column(name = "max_attempts", nullable = false) + var maxAttempts: Int, + + @Column(name = "next_attempt_at", nullable = false) + var nextAttemptAt: LocalDateTime, + + @Column(name = "locked_until") + var lockedUntil: LocalDateTime? = null, + + @Column(name = "last_error", columnDefinition = "TEXT") + var lastError: String? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime, + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime, + + @Column(name = "published_at") + var publishedAt: LocalDateTime? = null, +) diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxStatus.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxStatus.kt new file mode 100644 index 0000000..b053255 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/entity/RoutePassportOutboxStatus.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.routepassportconsumer.entity + +/** + * Состояние события outbox в жизненном цикле публикации. + */ +enum class RoutePassportOutboxStatus { + PENDING, + PUBLISHED, + FAILED, +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportContractException.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportContractException.kt new file mode 100644 index 0000000..26ea55a --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/exception/RoutePassportContractException.kt @@ -0,0 +1,6 @@ +package space.nstart.pcp.routepassportconsumer.exception + +class RoutePassportContractException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt new file mode 100644 index 0000000..ca7e332 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListener.kt @@ -0,0 +1,95 @@ +package space.nstart.pcp.routepassportconsumer.listener + +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.support.Acknowledgment +import org.springframework.stereotype.Component +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import space.nstart.pcp.routepassportconsumer.service.RoutePassportIngestionService +import space.nstart.pcp.routepassportconsumer.service.RoutePassportJsonParserService +import space.nstart.pcp.routepassportconsumer.service.RoutePassportSourceRecordMetadata + +/** + * Kafka listener, который получает JSON паспорта маршрута и передаёт его в транзакционный ingestion. + */ +@Component +class RoutePassportMessageListener( + + /** Сервис парсинга входного JSON сообщения в итоговый DTO. */ + private val routePassportJsonParserService: RoutePassportJsonParserService, + + /** Сервис транзакционного сохранения паспорта маршрута и outbox-событий. */ + private val routePassportIngestionService: RoutePassportIngestionService, + + /** Набор Kafka topic'ов, который приходит из config server и валидируется при старте. */ + val kafkaTopicsProperties: KafkaTopicsProperties, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + /** + * Обрабатывает входное сообщение Kafka. + * + * Offset подтверждается только после успешного возврата из transactional ingestion. + */ + @KafkaListener( + topics = ["#{__listener.kafkaTopicsProperties.input}"], + groupId = "\${spring.kafka.consumer.group-id}", + ) + fun listen( + record: ConsumerRecord, + acknowledgment: Acknowledgment, + ) { + val rawPayload = record.value() + val sourceRecord = RoutePassportSourceRecordMetadata( + topic = record.topic(), + partition = record.partition(), + offset = record.offset(), + key = record.key(), + ) + + try { + log.debug( + "Kafka record received: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}", + sourceRecord.topic, + sourceRecord.partition, + sourceRecord.offset, + sourceRecord.key, + rawPayload.length, + ) + + val parsedMessage = routePassportJsonParserService.parse(rawPayload) + routePassportIngestionService.ingest( + parsedMessage = parsedMessage, + rawPayload = rawPayload, + sourceRecord = sourceRecord, + ) + + // Transactional ingest commits before returning; only then the Kafka offset is acknowledged. + acknowledgment.acknowledge() + + log.info( + "Route passport ingested: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}, routeId={}, traceId={}, correlationId={}", + sourceRecord.topic, + sourceRecord.partition, + sourceRecord.offset, + sourceRecord.key, + rawPayload.length, + parsedMessage.routePassportDto.routeId, + parsedMessage.metadata.traceId, + parsedMessage.metadata.correlationId, + ) + } catch (exception: Exception) { + log.error( + "Failed to process Kafka record: sourceTopic={}, partition={}, offset={}, key={}, error={}", + sourceRecord.topic, + sourceRecord.partition, + sourceRecord.offset, + sourceRecord.key, + exception.message, + exception, + ) + throw exception + } + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt new file mode 100644 index 0000000..8d7bb40 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportOutboxRepository.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.routepassportconsumer.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity +import java.util.UUID + +interface RoutePassportOutboxRepository : JpaRepository { + fun existsByRouteIdAndEventType(routeId: UUID, eventType: String): Boolean + + @Query( + value = """ + SELECT * + FROM route_passport_outbox + WHERE status = 'PENDING' + AND next_attempt_at <= now() + AND (locked_until IS NULL OR locked_until < now()) + ORDER BY created_at + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true, + ) + fun findDueForUpdateSkipLocked(@Param("limit") limit: Int): List +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt new file mode 100644 index 0000000..58f8a23 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/repository/RoutePassportRepository.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.routepassportconsumer.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity +import java.util.UUID + +interface RoutePassportRepository : JpaRepository diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverter.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverter.kt new file mode 100644 index 0000000..e96c1e7 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverter.kt @@ -0,0 +1,143 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.LinearRing +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTWriter +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto +import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException +import tools.jackson.databind.JsonNode + +/** + * Конвертирует поддерживаемую GeoJSON geometry входного паспорта маршрута в WKT. + */ +@Service +open class RouteGeometryConverter { + private val geometryFactory = GeometryFactory() + private val wktWriter = WKTWriter() + + open fun convert(geometry: RawGeoJsonGeometryDto?): String { + if (geometry == null) { + throw RoutePassportContractException("Отсутствует обязательное поле geometry") + } + + val jtsGeometry = when (geometry.type) { + "Polygon" -> toPolygon(geometry.coordinates, fieldName = "geometry.coordinates") + "MultiPolygon" -> toMultiPolygon(geometry.coordinates) + else -> throw RoutePassportContractException("Неподдерживаемый тип geometry: ${geometry.type}") + } + + return writeCompactWkt(jtsGeometry) + } + + private fun toMultiPolygon(coordinatesNode: JsonNode): Geometry { + requireArray(coordinatesNode, "geometry.coordinates") + + if (coordinatesNode.size() == 0) { + throw RoutePassportContractException("MultiPolygon geometry.coordinates не должен быть пустым") + } + + val polygons = coordinatesNode.mapIndexed { index, polygonNode -> + toPolygon(polygonNode, fieldName = "geometry.coordinates[$index]") + }.toTypedArray() + + return geometryFactory.createMultiPolygon(polygons) + } + + private fun toPolygon( + coordinatesNode: JsonNode, + fieldName: String, + ): Polygon { + requireArray(coordinatesNode, fieldName) + + if (coordinatesNode.size() == 0) { + throw RoutePassportContractException("Polygon $fieldName должен содержать хотя бы один linear ring") + } + + val shell = toLinearRing(coordinatesNode[0], "$fieldName[0]") + val holes = coordinatesNode.drop(1).mapIndexed { index, ringNode -> + toLinearRing(ringNode, "$fieldName[${index + 1}]") + }.toTypedArray() + + return try { + geometryFactory.createPolygon(shell, holes) + } catch (exception: IllegalArgumentException) { + throw RoutePassportContractException("Некорректная структура Polygon $fieldName", exception) + } + } + + private fun toLinearRing( + ringNode: JsonNode, + fieldName: String, + ): LinearRing { + requireArray(ringNode, fieldName) + + if (ringNode.size() < MIN_LINEAR_RING_POINTS) { + throw RoutePassportContractException( + "Linear ring $fieldName должен содержать минимум $MIN_LINEAR_RING_POINTS точки", + ) + } + + val coordinates = ringNode.mapIndexed { index, coordinateNode -> + toCoordinate(coordinateNode, "$fieldName[$index]") + }.toTypedArray() + + if (!coordinates.first().equals2D(coordinates.last())) { + throw RoutePassportContractException("Linear ring $fieldName должен быть замкнут") + } + + return try { + geometryFactory.createLinearRing(coordinates) + } catch (exception: IllegalArgumentException) { + throw RoutePassportContractException("Некорректная структура linear ring $fieldName", exception) + } + } + + private fun toCoordinate( + coordinateNode: JsonNode, + fieldName: String, + ): Coordinate { + requireArray(coordinateNode, fieldName) + + if (coordinateNode.size() < MIN_COORDINATE_VALUES) { + throw RoutePassportContractException("Точка $fieldName должна содержать минимум две координаты") + } + + if (!coordinateNode[0].isNumber || !coordinateNode[1].isNumber) { + throw RoutePassportContractException("Точка $fieldName должна содержать числовые longitude и latitude") + } + + return try { + Coordinate( + coordinateNode[0].decimalValue().toDouble(), + coordinateNode[1].decimalValue().toDouble(), + ) + } catch (exception: RuntimeException) { + throw RoutePassportContractException("Точка $fieldName содержит некорректные координаты", exception) + } + } + + private fun requireArray( + node: JsonNode, + fieldName: String, + ) { + if (!node.isArray) { + throw RoutePassportContractException("Поле $fieldName должно быть массивом GeoJSON") + } + } + + private fun writeCompactWkt(geometry: Geometry): String = + when (geometry.geometryType) { + "Polygon" -> wktWriter.write(geometry).removePrefix("POLYGON ").let { "POLYGON$it" } + "MultiPolygon" -> wktWriter.write(geometry).removePrefix("MULTIPOLYGON ").let { "MULTIPOLYGON$it" } + else -> wktWriter.write(geometry) + } + + private companion object { + const val MIN_COORDINATE_VALUES = 2 + const val MIN_LINEAR_RING_POINTS = 4 + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt new file mode 100644 index 0000000..8d6ef1c --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt @@ -0,0 +1,200 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus +import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository +import space.nstart.pcp.routepassportconsumer.repository.RoutePassportRepository +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import java.util.UUID + +/** + * Сохраняет разобранный route passport и outbox-события в одной DB transaction. + */ +@Service +class RoutePassportIngestionService( + private val routePassportRepository: RoutePassportRepository, + private val routePassportOutboxRepository: RoutePassportOutboxRepository, + private val objectMapper: ObjectMapper, + private val kafkaTopicsProperties: KafkaTopicsProperties, +) { + + @Transactional + fun ingest( + parsedMessage: ParsedRoutePassportMessage, + rawPayload: String, + sourceRecord: RoutePassportSourceRecordMetadata, + ) { + val now = LocalDateTime.now() + val routePassportDto = parsedMessage.routePassportDto + + val routePassportEntity = routePassportRepository.findById(routePassportDto.routeId) + .map { existingEntity -> + existingEntity.applyRoutePassport( + routePassportDto = routePassportDto, + parsedMessage = parsedMessage, + rawPayload = rawPayload, + sourceRecord = sourceRecord, + updatedAt = now, + ) + } + .orElseGet { + routePassportDto.toEntity( + parsedMessage = parsedMessage, + rawPayload = rawPayload, + sourceRecord = sourceRecord, + now = now, + ) + } + + routePassportRepository.save(routePassportEntity) + + createOutboxIfAbsent( + eventType = PcpKafkaEvent.ModeStatusChangedEvent, + topic = kafkaTopicsProperties.processedRoute, + parsedMessage = parsedMessage, + now = now, + ) + createOutboxIfAbsent( + eventType = PcpKafkaEvent.RouteGeoRefEvent, + topic = kafkaTopicsProperties.routeGeoreference, + parsedMessage = parsedMessage, + now = now, + ) + } + + private fun createOutboxIfAbsent( + eventType: PcpKafkaEvent, + topic: String, + parsedMessage: ParsedRoutePassportMessage, + now: LocalDateTime, + ) { + val routeId = parsedMessage.routePassportDto.routeId + val eventTypeName = eventType.name + + if (routePassportOutboxRepository.existsByRouteIdAndEventType(routeId, eventTypeName)) { + return + } + + routePassportOutboxRepository.save( + RoutePassportOutboxEntity( + id = UUID.randomUUID(), + routeId = routeId, + eventType = eventTypeName, + topic = topic, + messageKey = routeId.toString(), + payload = buildOutboxPayload(eventType, parsedMessage), + headers = null, + status = RoutePassportOutboxStatus.PENDING, + attempts = 0, + maxAttempts = 10, + nextAttemptAt = now, + lockedUntil = null, + lastError = null, + createdAt = now, + updatedAt = now, + publishedAt = null, + ), + ) + } + + private fun buildOutboxPayload( + eventType: PcpKafkaEvent, + parsedMessage: ParsedRoutePassportMessage, + ): JsonNode { + val message = KafkaMessage( + type = eventType, + data = parsedMessage.routePassportDto, + traceId = parsedMessage.metadata.traceId, + correlationId = parsedMessage.metadata.correlationId, + group = "pcp", + ).apply { + source = "route-processing" + } + + return objectMapper.valueToTree(message) + } + + private fun RoutePassportEntity.applyRoutePassport( + routePassportDto: RoutePassportDto, + parsedMessage: ParsedRoutePassportMessage, + rawPayload: String, + sourceRecord: RoutePassportSourceRecordMetadata, + updatedAt: LocalDateTime, + ): RoutePassportEntity { + routeNameFull = routePassportDto.routeNameFull + routeNameShort = routePassportDto.routeNameShort + kaShort = routePassportDto.kaShort + kaFull = routePassportDto.kaFull + orbitNumber = routePassportDto.orbitNumber + orbitState = routePassportDto.orbitState + intervalBegin = routePassportDto.intervalBegin + intervalEnd = routePassportDto.intervalEnd + rollAngleMin = routePassportDto.rollAngle.min + rollAngleMax = routePassportDto.rollAngle.max + visirAngleMin = routePassportDto.visirAngle.min + visirAngleMax = routePassportDto.visirAngle.max + resolutionRange = routePassportDto.resolutionRange + resolutionAzimuth = routePassportDto.resolutionAzimuth + polarisation = objectMapper.valueToTree(routePassportDto.polarisation) + processingLevel = routePassportDto.processingLevel + geometryWkt = routePassportDto.geometry + routeStatus = routePassportDto.routeStatus.name + traceId = parsedMessage.metadata.traceId + correlationId = parsedMessage.metadata.correlationId + sourceTopic = sourceRecord.topic + sourcePartition = sourceRecord.partition + sourceOffset = sourceRecord.offset + sourceKey = sourceRecord.key + this.rawPayload = objectMapper.readTree(rawPayload) + this.updatedAt = updatedAt + + return this + } + + private fun RoutePassportDto.toEntity( + parsedMessage: ParsedRoutePassportMessage, + rawPayload: String, + sourceRecord: RoutePassportSourceRecordMetadata, + now: LocalDateTime, + ): RoutePassportEntity = + RoutePassportEntity( + routeId = routeId, + routeNameFull = routeNameFull, + routeNameShort = routeNameShort, + kaShort = kaShort, + kaFull = kaFull, + orbitNumber = orbitNumber, + orbitState = orbitState, + intervalBegin = intervalBegin, + intervalEnd = intervalEnd, + rollAngleMin = rollAngle.min, + rollAngleMax = rollAngle.max, + visirAngleMin = visirAngle.min, + visirAngleMax = visirAngle.max, + resolutionRange = resolutionRange, + resolutionAzimuth = resolutionAzimuth, + polarisation = objectMapper.valueToTree(polarisation), + processingLevel = processingLevel, + geometryWkt = geometry, + routeStatus = routeStatus.name, + traceId = parsedMessage.metadata.traceId, + correlationId = parsedMessage.metadata.correlationId, + sourceTopic = sourceRecord.topic, + sourcePartition = sourceRecord.partition, + sourceOffset = sourceRecord.offset, + sourceKey = sourceRecord.key, + rawPayload = objectMapper.readTree(rawPayload), + createdAt = now, + updatedAt = now, + ) +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt new file mode 100644 index 0000000..208d351 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserService.kt @@ -0,0 +1,108 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage +import space.nstart.pcp.pcp_types_lib.dto.routes.RawFeaturePassportJsonDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RawPassportEnvelopeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata +import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException +import tools.jackson.core.JacksonException +import tools.jackson.databind.ObjectMapper +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.util.UUID + +/** + * Сервис, который парсит JSON паспорта маршрута в итоговый DTO. + */ +@Service +class RoutePassportJsonParserService( + + /** Jackson ObjectMapper, который выполняет базовую десериализацию JSON. */ + private val objectMapper: ObjectMapper, + + /** Converter GeoJSON geometry входного паспорта маршрута в WKT. */ + private val routeGeometryConverter: RouteGeometryConverter, +) { + + /** + * Парсит входной JSON и возвращает готовый DTO. + * + * @param json исходное Kafka-сообщение envelope-формата с полезной нагрузкой в поле `data`. + * @return DTO с уже преобразованными полями. + */ + fun parse(json: String): ParsedRoutePassportMessage { + val rawEnvelope: RawPassportEnvelopeDto = try { + objectMapper.readValue(json, RawPassportEnvelopeDto::class.java) + } catch (exception: JacksonException) { + throw RoutePassportContractException("Некорректный JSON contract паспорта маршрута", exception) + } + val routePassportDto = rawEnvelope.data.toRoutePassportDto() + + return ParsedRoutePassportMessage( + metadata = RoutePassportMessageMetadata( + traceId = rawEnvelope.traceId, + correlationId = rawEnvelope.correlationId, + ), + routePassportDto = routePassportDto, + ) + } + + /** + * Преобразует feature из envelope-сообщения в бизнес DTO. + */ + private fun RawFeaturePassportJsonDto.toRoutePassportDto(): RoutePassportDto { + val routePassportDto = RoutePassportDto( + routeId = routeId ?: UUID.nameUUIDFromBytes(id.toByteArray(StandardCharsets.UTF_8)), + kaShort = properties.missionId, + kaFull = properties.platform, + routeNameFull = id, + routeNameShort = collection, + orbitNumber = properties.orbitNumber.toLong(), + orbitState = properties.orbitState, + intervalBegin = LocalDateTime.parse(properties.intervalBegin), + intervalEnd = LocalDateTime.parse(properties.intervalEnd), + rollAngle = properties.rollAngle.toAngleRangeDto(fieldName = "rollAngle"), + visirAngle = properties.visirAngle.toAngleRangeDto(fieldName = "visirAngle"), + resolutionRange = properties.resolutionRange.toBigDecimal(), + resolutionAzimuth = properties.resolutionAzimuth.toBigDecimal(), + polarisation = properties.polarisation, + processingLevel = properties.processingLevel, + geometry = routeGeometryConverter.convert(geometry), + ) + + return routePassportDto + } + + /** + * Преобразует список чисел в DTO диапазона. + * + * @receiver список значений диапазона. + * @param fieldName имя поля для текста ошибки. + * @return DTO диапазона с минимальным и максимальным значением. + */ + private fun List.toAngleRangeDto(fieldName: String): AngleRangeDto { + if (this.isEmpty()) { + throw RoutePassportContractException("Поле $fieldName не должно быть пустым") + } + + /** Минимальное значение диапазона. */ + val minValue: BigDecimal = this.minOrNull() + ?: throw RoutePassportContractException("Не удалось определить минимум для поля $fieldName") + + /** Максимальное значение диапазона. */ + val maxValue: BigDecimal = this.maxOrNull() + ?: throw RoutePassportContractException("Не удалось определить максимум для поля $fieldName") + + /** DTO диапазона после вычисления минимума и максимума. */ + val angleRangeDto: AngleRangeDto = AngleRangeDto( + min = minValue, + max = maxValue, + ) + + return angleRangeDto + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt new file mode 100644 index 0000000..bc39e4a --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherService.kt @@ -0,0 +1,113 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.SendResult +import org.springframework.stereotype.Service +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import tools.jackson.databind.ObjectMapper +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture + +/** + * Сервис публикации обработанного маршрута в Kafka. + */ +@Service +class RoutePassportKafkaPublisherService( + /** ObjectMapper сериализует итоговый DTO в JSON перед отправкой. */ + private val objectMapper: ObjectMapper, + + /** KafkaTemplate отправляет одно и то же сообщение в несколько topic'ов. */ + private val kafkaTemplate: KafkaTemplate, + + /** Набор Kafka topic'ов, в которые публикуется результат обработки. */ + private val kafkaTopicsProperties: KafkaTopicsProperties, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + /** + * Публикует полный RoutePassportDto в оба выходных topic'а. + */ + fun publish(parsedMessage: ParsedRoutePassportMessage) { + val routePassportDto = parsedMessage.routePassportDto + + log.info( + "Publishing route passport: routeNameFull={}, processedRouteTopic={}, routeGeoreferenceTopic={}", + routePassportDto.routeNameFull, + kafkaTopicsProperties.processedRoute, + kafkaTopicsProperties.routeGeoreference, + ) + + sendWithLogging( + topic = kafkaTopicsProperties.processedRoute, + eventType = PcpKafkaEvent.ModeStatusChangedEvent, + payload = buildPayload( + eventType = PcpKafkaEvent.ModeStatusChangedEvent, + parsedMessage = parsedMessage, + ), + traceId = parsedMessage.metadata.traceId, + routeNameFull = routePassportDto.routeNameFull, + ) + sendWithLogging( + topic = kafkaTopicsProperties.routeGeoreference, + eventType = PcpKafkaEvent.RouteGeoRefEvent, + payload = buildPayload( + eventType = PcpKafkaEvent.RouteGeoRefEvent, + parsedMessage = parsedMessage, + ), + traceId = parsedMessage.metadata.traceId, + routeNameFull = routePassportDto.routeNameFull, + ) + } + + private fun buildPayload(eventType: PcpKafkaEvent, parsedMessage: ParsedRoutePassportMessage): String { + val message = KafkaMessage( + type = eventType, + data = parsedMessage.routePassportDto, + traceId = parsedMessage.metadata.traceId, + correlationId = parsedMessage.metadata.correlationId, + group = "pcp", + ).apply { + source = "route-processing" + } + + return objectMapper.writeValueAsString(message) + } + + private fun sendWithLogging( + topic: String, + eventType: PcpKafkaEvent, + payload: String, + traceId: String, + routeNameFull: String, + ) { + val record = ProducerRecord(topic, traceId, payload) + record.headers().add("type", eventType.name.toByteArray(StandardCharsets.UTF_8)) + + val future: CompletableFuture> = kafkaTemplate.send(record) + future.whenComplete { result, exception -> + if (exception == null) { + log.info( + "Kafka message published: routeNameFull={}, topic={}, type={}, partition={}, offset={}", + routeNameFull, + topic, + eventType, + result.recordMetadata.partition(), + result.recordMetadata.offset(), + ) + } else { + log.error( + "Failed to publish kafka message: routeNameFull={}, topic={}, type={}", + routeNameFull, + topic, + eventType, + exception, + ) + } + } + } +} diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportSourceRecordMetadata.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportSourceRecordMetadata.kt new file mode 100644 index 0000000..487635c --- /dev/null +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportSourceRecordMetadata.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.routepassportconsumer.service + +/** + * Metadata входной Kafka record, из которой был получен route passport. + */ +data class RoutePassportSourceRecordMetadata( + val topic: String, + val partition: Int, + val offset: Long, + val key: String?, +) diff --git a/services/pcp-route-processing-service/src/main/resources/application.yml b/services/pcp-route-processing-service/src/main/resources/application.yml new file mode 100644 index 0000000..7cdda83 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/resources/application.yml @@ -0,0 +1,28 @@ +spring: + application: + name: pcp-route-processing-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888} + fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} + profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} + label: ${SPRING_CLOUD_CONFIG_LABEL:master} + datasource: + driver-class-name: org.postgresql.Driver + url: ${PCP_ROUTE_PROCESSING_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_route_processing} + username: ${PCP_ROUTE_PROCESSING_DATASOURCE_USERNAME:postgres} + password: ${PCP_ROUTE_PROCESSING_DATASOURCE_PASSWORD:password} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration diff --git a/services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql b/services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql new file mode 100644 index 0000000..27be786 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/resources/db/migration/V1__create_route_passports.sql @@ -0,0 +1,46 @@ +CREATE TABLE route_passports ( + route_id UUID PRIMARY KEY, + route_name_full TEXT NOT NULL, + route_name_short VARCHAR(255) NOT NULL, + ka_short VARCHAR(64) NOT NULL, + ka_full VARCHAR(255) NOT NULL, + orbit_number BIGINT NOT NULL, + orbit_state VARCHAR(64) NOT NULL, + interval_begin TIMESTAMP NOT NULL, + interval_end TIMESTAMP NOT NULL, + roll_angle_min NUMERIC NOT NULL, + roll_angle_max NUMERIC NOT NULL, + visir_angle_min NUMERIC NOT NULL, + visir_angle_max NUMERIC NOT NULL, + resolution_range NUMERIC NOT NULL, + resolution_azimuth NUMERIC NOT NULL, + polarisation JSONB NOT NULL, + processing_level VARCHAR(64) NOT NULL, + geometry_wkt TEXT NOT NULL, + route_status VARCHAR(32) NOT NULL, + trace_id VARCHAR(255), + correlation_id VARCHAR(255), + source_topic VARCHAR(255), + source_partition INTEGER, + source_offset BIGINT, + source_key VARCHAR(255), + raw_payload JSONB, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_route_passports_interval + ON route_passports (interval_begin, interval_end); + +CREATE INDEX idx_route_passports_ka_orbit + ON route_passports (ka_short, orbit_number); + +CREATE INDEX idx_route_passports_status + ON route_passports (route_status); + +CREATE UNIQUE INDEX ux_route_passports_source_record + ON route_passports (source_topic, source_partition, source_offset) + WHERE source_topic IS NOT NULL + AND source_partition IS NOT NULL + AND source_offset IS NOT NULL; diff --git a/services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql b/services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql new file mode 100644 index 0000000..4700b67 --- /dev/null +++ b/services/pcp-route-processing-service/src/main/resources/db/migration/V2__create_route_passport_outbox.sql @@ -0,0 +1,38 @@ +CREATE TABLE route_passport_outbox ( + id UUID PRIMARY KEY, + route_id UUID NOT NULL, + event_type VARCHAR(128) NOT NULL, + topic VARCHAR(255) NOT NULL, + message_key VARCHAR(255) NOT NULL, + payload JSONB NOT NULL, + headers JSONB, + status VARCHAR(32) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 10, + next_attempt_at TIMESTAMP NOT NULL, + locked_until TIMESTAMP, + last_error TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + published_at TIMESTAMP, + + CONSTRAINT fk_route_passport_outbox_route + FOREIGN KEY (route_id) + REFERENCES route_passports (route_id) + ON DELETE CASCADE, + + CONSTRAINT ux_route_passport_outbox_route_event + UNIQUE (route_id, event_type), + + CONSTRAINT chk_route_passport_outbox_status + CHECK (status IN ('PENDING', 'PUBLISHED', 'FAILED')) +); + +CREATE INDEX idx_route_passport_outbox_due + ON route_passport_outbox (status, next_attempt_at, created_at); + +CREATE INDEX idx_route_passport_outbox_locked_until + ON route_passport_outbox (locked_until); + +CREATE INDEX idx_route_passport_outbox_route_id + ON route_passport_outbox (route_id); diff --git a/services/pcp-route-processing-service/src/main/resources/test-passport-message.json b/services/pcp-route-processing-service/src/main/resources/test-passport-message.json new file mode 100644 index 0000000..0ded04d --- /dev/null +++ b/services/pcp-route-processing-service/src/main/resources/test-passport-message.json @@ -0,0 +1,207 @@ +{ + "id": "51b07d12-1823-4bd5-b55d-de02a5304c03", + "traceid": "c0dd08ea-dd64-4633-8845-d1b163bdf506", + "correlationid": "db605d5f-77e9-45b4-82cc-709837306257", + "group": "stac_test", + "source": "stac-writer-service", + "type": "METADATA_NORMALIZED", + "time": "2026-03-31T09:39:13.251462", + "data": { + "id": "S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE", + "routeId": "51b07d12-1823-4bd5-b55d-de02a5304c03", + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 67.675827, + 38.114815 + ], + [ + 64.779434, + 38.514816 + ], + [ + 65.094017, + 40.015545 + ], + [ + 68.05294, + 39.616795 + ], + [ + 67.675827, + 38.114815 + ] + ] + ] + }, + "bbox": [ + 64.779434, + 40.015545, + 68.05294, + 38.114815 + ], + "properties": { + "datetime": "2026-03-31T12:08:30.570573Z", + "created": null, + "updated": null, + "platform": "sentinel-1a", + "instruments": null, + "processing:level": "GRD", + "route:orbit_number": "59796", + "missionId": "S1A", + "sat:orbit_state": "Descending", + "polarisation": [ + "VH", + "VV" + ], + "rollAngle": [ + 30.07864486915724, + 30.09378936608302 + ], + "visirAngle": [ + 30.63331560676386, + 46.07086702096005 + ], + "intervalBegin": "2025-06-25T01:38:54.481190", + "intervalEnd": "2025-06-25T01:39:19.479675", + "resolutionRange": "1.000000e+01", + "resolutionAzimuth": "1.000000e+01" + }, + "links": [ + { + "rel": "self", + "href": "http://app-opensearch:8082/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE", + "type": "application/geo+json", + "title": null + }, + { + "rel": "parent", + "href": "http://app-opensearch:8082/collections/S1A-GRDH", + "type": "application/json", + "title": null + }, + { + "rel": "collection", + "href": "http://app-opensearch:8082/collections/S1A-GRDH", + "type": "application/json", + "title": null + }, + { + "rel": "root", + "href": "http://app-opensearch:8082", + "type": "application/json", + "title": null + } + ], + "assets": { + "schema-calibration-vh": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939", + "title": "xml", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "schema-calibration-vv": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0", + "title": "xml", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "schema-noise-vh": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c", + "title": "xml", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "schema-noise-vv": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca", + "title": "xml", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "schema-product-vh": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f", + "title": "xml", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "schema-product-vv": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee", + "title": "xml", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "safe-manifest": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53", + "title": "safe", + "description": null, + "type": "application/xml", + "roles": [ + "metadata" + ] + }, + "vh": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648", + "title": "tif", + "description": null, + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "visual" + ] + }, + "vv": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb", + "title": "tif", + "description": null, + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "visual" + ] + }, + "kml": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54", + "title": "kml", + "description": null, + "type": "application/kml", + "roles": [ + "metadata" + ] + }, + "thumbnail": { + "href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0", + "title": "png", + "description": null, + "type": "image/png", + "roles": [ + "thumbnail", + "overview" + ] + } + }, + "collection": "S1A-GRDH", + "stac_version": "1.0.0", + "stac_extensions": null + }, + "datacontenttype": "application/json", + "specversion": "1.0" +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfigTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfigTest.kt new file mode 100644 index 0000000..846d53f --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/config/KafkaConsumerConfigTest.kt @@ -0,0 +1,190 @@ +package space.nstart.pcp.routepassportconsumer.config + +import org.apache.kafka.clients.consumer.Consumer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.producer.ProducerRecord +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.kafka.listener.MessageListenerContainer +import org.springframework.kafka.support.KafkaHeaders +import org.springframework.kafka.support.SendResult +import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture + +class KafkaConsumerConfigTest { + + private val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java)) + .withUserConfiguration(TestPropertiesConfiguration::class.java, KafkaConsumerConfig::class.java) + .withPropertyValues( + "spring.kafka.bootstrap-servers=localhost:9092", + "spring.kafka.consumer.group-id=pcp-route-processing-service", + "spring.kafka.consumer.auto-offset-reset=earliest", + "app.kafka.topics.input=$INPUT_TOPIC", + "app.kafka.topics.input-dlq=$INPUT_DLQ_TOPIC", + "app.kafka.topics.processed-route=pcp.route.in.v1", + "app.kafka.topics.route-georeference=pcp.route.georeference.v1", + "app.route-passport.consumer.retry.max-attempts=3", + "app.route-passport.consumer.retry.backoff-ms=0", + "app.route-passport.consumer.retry.max-backoff-ms=0", + ) + + @Test + fun `should create error handler and recoverer beans`() { + contextRunner.run { context -> + assertThat(context).hasSingleBean(DefaultErrorHandler::class.java) + assertThat(context).hasSingleBean(DeadLetterPublishingRecoverer::class.java) + + val retryProperties = context.getBean(RoutePassportConsumerRetryProperties::class.java) + assertThat(retryProperties.maxAttempts).isEqualTo(3) + assertThat(retryProperties.backoffMs).isZero() + assertThat(retryProperties.maxBackoffMs).isZero() + } + } + + @Test + fun `dead letter recoverer should publish original string record to configured input dlq topic`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java) + val record = consumerRecord() + + recoverer.accept(record, RoutePassportContractException("payload contract is invalid")) + + val publishedRecord = capturePublishedRecord(kafkaTemplate) + assertThat(publishedRecord.topic()).isEqualTo(INPUT_DLQ_TOPIC) + assertThat(publishedRecord.partition()).isEqualTo(record.partition()) + assertThat(publishedRecord.key()).isEqualTo(record.key()) + assertThat(publishedRecord.value()).isEqualTo(record.value()) + assertThat(publishedRecord.headers().lastStringHeader("pcp-service-name")) + .isEqualTo("pcp-route-processing-service") + assertThat(publishedRecord.headers().lastStringHeader("pcp-exception-class")) + .isEqualTo(RoutePassportContractException::class.java.name) + assertThat(publishedRecord.headers().lastStringHeader("pcp-exception-message")) + .isEqualTo("payload contract is invalid") + assertThat(publishedRecord.headers().lastStringHeader("pcp-original-topic")).isEqualTo(INPUT_TOPIC) + assertThat(publishedRecord.headers().lastStringHeader("pcp-original-partition")).isEqualTo("3") + assertThat(publishedRecord.headers().lastStringHeader("pcp-original-offset")).isEqualTo("42") + assertThat(publishedRecord.headers().lastStringHeader(KafkaHeaders.DLT_ORIGINAL_TOPIC)) + .isEqualTo(INPUT_TOPIC) + assertThat(publishedRecord.headers().lastStringHeader(KafkaHeaders.DLT_EXCEPTION_FQCN)) + .isEqualTo(RoutePassportContractException::class.java.name) + } + } + + @Test + fun `route passport contract exception should be recovered without retry`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val errorHandler = context.getBean(DefaultErrorHandler::class.java) + + val handled = errorHandler.handleOne( + RoutePassportContractException("payload contract is invalid"), + consumerRecord(), + mockConsumer(), + mock(MessageListenerContainer::class.java), + ) + + assertThat(handled).isTrue() + verify(kafkaTemplate, times(1)).send(anyProducerRecord()) + } + } + + @Test + fun `runtime exception should be retried with bounded policy before dlq recovery`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val errorHandler = context.getBean(DefaultErrorHandler::class.java) + val record = consumerRecord() + val consumer = mockConsumer() + val container = mock(MessageListenerContainer::class.java) + val exception = IllegalStateException("database is temporarily unavailable") + + assertThat(errorHandler.handleOne(exception, record, consumer, container)).isFalse() + assertThat(errorHandler.handleOne(exception, record, consumer, container)).isFalse() + verify(kafkaTemplate, never()).send(anyProducerRecord()) + + assertThat(errorHandler.handleOne(exception, record, consumer, container)).isTrue() + verify(kafkaTemplate, times(1)).send(anyProducerRecord()) + } + } + + private fun consumerRecord(): ConsumerRecord = + ConsumerRecord( + INPUT_TOPIC, + 3, + 42L, + "route-key", + """{"id":"route-1"}""", + ) + + private fun capturePublishedRecord( + kafkaTemplate: KafkaTemplate, + ): ProducerRecord { + val captor = producerRecordCaptor() + verify(kafkaTemplate, times(1)).send(captor.capture()) + return captor.value + } + + @Suppress("UNCHECKED_CAST") + private fun producerRecordCaptor(): ArgumentCaptor> = + ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor> + + @Suppress("UNCHECKED_CAST") + private fun mockConsumer(): Consumer = + mock(Consumer::class.java) as Consumer + + private fun org.apache.kafka.common.header.Headers.lastStringHeader(name: String): String? = + lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8) + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties( + KafkaTopicsProperties::class, + RoutePassportConsumerRetryProperties::class, + ) + private class TestPropertiesConfiguration { + + @Bean + @Suppress("UNCHECKED_CAST") + fun kafkaTemplate(): KafkaTemplate { + val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + val sendResult = mock(SendResult::class.java) as SendResult + + `when`(kafkaTemplate.send(anyProducerRecord())) + .thenReturn(CompletableFuture.completedFuture(sendResult)) + + return kafkaTemplate + } + } + + private companion object { + const val INPUT_TOPIC = "pcp.request.survey-georeference.v1" + const val INPUT_DLQ_TOPIC = "pcp.request.survey-georeference-dlq.v1" + + @Suppress("UNCHECKED_CAST") + fun anyProducerRecord(): ProducerRecord = + (any(ProducerRecord::class.java) as ProducerRecord?) + ?: ProducerRecord("stub-topic", "stub-key", "stub-payload") + + @Suppress("UNCHECKED_CAST") + fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate = + getBean(KafkaTemplate::class.java) as KafkaTemplate + } +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/controller/RouteProcessingTestControllerTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/controller/RouteProcessingTestControllerTest.kt new file mode 100644 index 0000000..e0c4320 --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/controller/RouteProcessingTestControllerTest.kt @@ -0,0 +1,63 @@ +package space.nstart.pcp.routepassportconsumer.controller + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import java.nio.charset.StandardCharsets + +class RouteProcessingTestControllerTest { + @Suppress("UNCHECKED_CAST") + private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + private val kafkaTopicsProperties = KafkaTopicsProperties( + input = "pcp.request.survey-georeference.v1", + processedRoute = "pcp.route.in.v1", + routeGeoreference = "pcp.route.georeference.v1", + ) + + private val mockMvc: MockMvc = MockMvcBuilders + .standaloneSetup(RouteProcessingTestController(kafkaTemplate, kafkaTopicsProperties)) + .build() + + @Test + fun `GET test endpoint returns service status`() { + mockMvc.get("/api/route-processing/test") + .andExpect { + status { isOk() } + jsonPath("$.status") { value("ok") } + jsonPath("$.service") { value("pcp-route-processing-service") } + } + } + + @Test + fun `POST send endpoint publishes test passport message to input topic`() { + mockMvc.post("/api/route-processing/test/send-passport-message") + .andExpect { + status { isOk() } + jsonPath("$.status") { value("sent") } + jsonPath("$.topic") { value("pcp.request.survey-georeference.v1") } + jsonPath("$.resource") { value("classpath:test-passport-message.json") } + } + + val payloadCaptor = ArgumentCaptor.forClass(String::class.java) + verify(kafkaTemplate, times(1)).send( + org.mockito.Mockito.eq("pcp.request.survey-georeference.v1"), + payloadCaptor.capture(), + ) + + val expectedPayload = this::class.java.classLoader + .getResourceAsStream("test-passport-message.json") + ?.readAllBytes() + ?.toString(StandardCharsets.UTF_8) + ?: error("Не найден файл test-passport-message.json в test resources") + + kotlin.test.assertEquals(expectedPayload, payloadCaptor.value) + } +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListenerTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListenerTest.kt new file mode 100644 index 0000000..a807be0 --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/listener/RoutePassportMessageListenerTest.kt @@ -0,0 +1,176 @@ +package space.nstart.pcp.routepassportconsumer.listener + +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.springframework.kafka.support.Acknowledgment +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import space.nstart.pcp.routepassportconsumer.service.RoutePassportIngestionService +import space.nstart.pcp.routepassportconsumer.service.RoutePassportJsonParserService +import space.nstart.pcp.routepassportconsumer.service.RoutePassportSourceRecordMetadata +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +class RoutePassportMessageListenerTest { + + @Test + fun `should parse incoming record, ingest it and acknowledge offset`() { + val parserService = mock(RoutePassportJsonParserService::class.java) + val ingestionService = mock(RoutePassportIngestionService::class.java) + val acknowledgment = mock(Acknowledgment::class.java) + val listener = RoutePassportMessageListener( + routePassportJsonParserService = parserService, + routePassportIngestionService = ingestionService, + kafkaTopicsProperties = kafkaTopicsProperties(), + ) + val rawPayload = """{"id":"route-1"}""" + val record = consumerRecord(rawPayload) + val parsedMessage = parsedMessage() + val sourceRecord = sourceRecordMetadata(record) + + `when`(parserService.parse(rawPayload)).thenReturn(parsedMessage) + + listener.listen(record, acknowledgment) + + verify(parserService).parse(rawPayload) + verify(ingestionService).ingest( + parsedMessage = parsedMessage, + rawPayload = rawPayload, + sourceRecord = sourceRecord, + ) + verify(acknowledgment, times(1)).acknowledge() + } + + @Test + fun `should not ingest or acknowledge when parser throws exception`() { + val parserService = mock(RoutePassportJsonParserService::class.java) + val ingestionService = mock(RoutePassportIngestionService::class.java) + val acknowledgment = mock(Acknowledgment::class.java) + val listener = RoutePassportMessageListener( + routePassportJsonParserService = parserService, + routePassportIngestionService = ingestionService, + kafkaTopicsProperties = kafkaTopicsProperties(), + ) + val rawPayload = """{"id":"route-1"}""" + val record = consumerRecord(rawPayload) + val exception = IllegalArgumentException("payload is invalid") + + `when`(parserService.parse(rawPayload)).thenThrow(exception) + + assertThrows(IllegalArgumentException::class.java) { + listener.listen(record, acknowledgment) + } + + verify(parserService).parse(rawPayload) + verifyNoInteractions(ingestionService) + verify(acknowledgment, never()).acknowledge() + } + + @Test + fun `should not acknowledge when ingestion throws exception`() { + val parserService = mock(RoutePassportJsonParserService::class.java) + val ingestionService = mock(RoutePassportIngestionService::class.java) + val acknowledgment = mock(Acknowledgment::class.java) + val listener = RoutePassportMessageListener( + routePassportJsonParserService = parserService, + routePassportIngestionService = ingestionService, + kafkaTopicsProperties = kafkaTopicsProperties(), + ) + val rawPayload = """{"id":"route-1"}""" + val record = consumerRecord(rawPayload) + val parsedMessage = parsedMessage() + val sourceRecord = sourceRecordMetadata(record) + val exception = IllegalStateException("database is unavailable") + + `when`(parserService.parse(rawPayload)).thenReturn(parsedMessage) + doThrow(exception).`when`(ingestionService).ingest( + parsedMessage = parsedMessage, + rawPayload = rawPayload, + sourceRecord = sourceRecord, + ) + + assertThrows(IllegalStateException::class.java) { + listener.listen(record, acknowledgment) + } + + verify(parserService).parse(rawPayload) + verify(ingestionService).ingest( + parsedMessage = parsedMessage, + rawPayload = rawPayload, + sourceRecord = sourceRecord, + ) + verify(acknowledgment, never()).acknowledge() + } + + private fun kafkaTopicsProperties(): KafkaTopicsProperties = + KafkaTopicsProperties( + input = INPUT_TOPIC, + processedRoute = "pcp.route.in.v1", + routeGeoreference = "pcp.route.georeference.v1", + ) + + private fun consumerRecord(rawPayload: String): ConsumerRecord = + ConsumerRecord( + INPUT_TOPIC, + 3, + 42L, + "route-key", + rawPayload, + ) + + private fun sourceRecordMetadata(record: ConsumerRecord): RoutePassportSourceRecordMetadata = + RoutePassportSourceRecordMetadata( + topic = record.topic(), + partition = record.partition(), + offset = record.offset(), + key = record.key(), + ) + + private fun parsedMessage(): ParsedRoutePassportMessage = + ParsedRoutePassportMessage( + metadata = RoutePassportMessageMetadata( + traceId = "trace-1", + correlationId = "corr-1", + ), + routePassportDto = RoutePassportDto( + routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"), + kaShort = "S1A", + kaFull = "sentinel-1a", + routeNameFull = "ROUTE-001", + routeNameShort = "S1A-GRDH", + orbitNumber = 59796L, + orbitState = "Descending", + intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0), + intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30), + rollAngle = AngleRangeDto( + min = BigDecimal("-15.5"), + max = BigDecimal("-12.0"), + ), + visirAngle = AngleRangeDto( + min = BigDecimal("21.0"), + max = BigDecimal("24.5"), + ), + resolutionRange = BigDecimal("10.0"), + resolutionAzimuth = BigDecimal("10.0"), + polarisation = listOf("VH", "VV"), + processingLevel = "GRD", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))", + ), + ) + + private companion object { + const val INPUT_TOPIC = "pcp.request.survey-georeference.v1" + } +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverterTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverterTest.kt new file mode 100644 index 0000000..5043e32 --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RouteGeometryConverterTest.kt @@ -0,0 +1,87 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto +import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.json.JsonMapper +import tools.jackson.module.kotlin.KotlinModule + +class RouteGeometryConverterTest { + private val objectMapper: ObjectMapper = JsonMapper.builder() + .addModule(KotlinModule.Builder().build()) + .build() + + private val converter = RouteGeometryConverter() + + @Test + fun `should convert valid polygon to wkt`() { + val geometry = geometry( + type = "Polygon", + coordinates = """[[[0, 0], [1, 0], [1, 1], [0, 0]]]""", + ) + + val wkt = converter.convert(geometry) + + assertTrue(wkt.startsWith("POLYGON")) + } + + @Test + fun `should convert valid multipolygon to wkt`() { + val geometry = geometry( + type = "MultiPolygon", + coordinates = """[[[[0, 0], [1, 0], [1, 1], [0, 0]]]]""", + ) + + val wkt = converter.convert(geometry) + + assertTrue(wkt.startsWith("MULTIPOLYGON")) + } + + @Test + fun `should reject unsupported geometry type`() { + val geometry = geometry( + type = "Point", + coordinates = """[0, 0]""", + ) + + assertThrows(RoutePassportContractException::class.java) { + converter.convert(geometry) + } + } + + @Test + fun `should reject malformed coordinates`() { + val geometry = geometry( + type = "Polygon", + coordinates = """[[[0, 0], [1, 0], [1, 1]]]""", + ) + + assertThrows(RoutePassportContractException::class.java) { + converter.convert(geometry) + } + } + + @Test + fun `should reject non numeric coordinate values`() { + val geometry = geometry( + type = "Polygon", + coordinates = """[[[0, 0], ["bad", 0], [1, 1], [0, 0]]]""", + ) + + assertThrows(RoutePassportContractException::class.java) { + converter.convert(geometry) + } + } + + private fun geometry( + type: String, + coordinates: String, + ): RawGeoJsonGeometryDto = + RawGeoJsonGeometryDto( + type = type, + coordinates = objectMapper.readTree(coordinates), + ) +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt new file mode 100644 index 0000000..cca75bd --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt @@ -0,0 +1,302 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity +import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus +import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository +import space.nstart.pcp.routepassportconsumer.repository.RoutePassportRepository +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.json.JsonMapper +import tools.jackson.module.kotlin.KotlinModule +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class RoutePassportIngestionServiceTest { + private val objectMapper: ObjectMapper = JsonMapper.builder() + .addModule(KotlinModule.Builder().build()) + .build() + + private val kafkaTopicsProperties = KafkaTopicsProperties( + input = "pcp.request.survey-georeference.v1", + processedRoute = "pcp.route.in.v1", + routeGeoreference = "pcp.route.georeference.v1", + ) + + @Test + fun `first ingest creates route passport and two pending outbox events`() { + val fixture = fixture() + val routePassportRepository = mock(RoutePassportRepository::class.java) + val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java) + val savedRoutes = mutableListOf() + val savedOutbox = mutableListOf() + val service = service(routePassportRepository, routePassportOutboxRepository) + + doReturn(Optional.empty()) + .`when`(routePassportRepository) + .findById(fixture.routeId) + captureRouteSaves(routePassportRepository, savedRoutes) + captureOutboxSaves(routePassportOutboxRepository, savedOutbox) + + service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord) + + assertEquals(1, savedRoutes.size) + assertEquals(fixture.routeId, savedRoutes.single().routeId) + assertEquals("ROUTE-001", savedRoutes.single().routeNameFull) + assertTrue(savedRoutes.single().polarisation.isArray) + assertEquals("""["VH","VV"]""", savedRoutes.single().polarisation.toString()) + assertTrue(savedRoutes.single().rawPayload?.isObject == true) + assertEquals("PROCESSED", savedRoutes.single().routeStatus) + + assertEquals(2, savedOutbox.size) + assertEquals( + listOf(PcpKafkaEvent.ModeStatusChangedEvent.name, PcpKafkaEvent.RouteGeoRefEvent.name), + savedOutbox.map { it.eventType }, + ) + assertEquals( + listOf("pcp.route.in.v1", "pcp.route.georeference.v1"), + savedOutbox.map { it.topic }, + ) + savedOutbox.forEach { outbox -> + assertEquals(fixture.routeId, outbox.routeId) + assertEquals(fixture.routeId.toString(), outbox.messageKey) + assertEquals(RoutePassportOutboxStatus.PENDING, outbox.status) + assertEquals(0, outbox.attempts) + assertEquals(10, outbox.maxAttempts) + assertNotNull(outbox.nextAttemptAt) + } + } + + @Test + fun `duplicate ingest updates route passport without creating duplicate outbox rows`() { + val fixture = fixture() + val existingEntity = existingRouteEntity(fixture.routeId).apply { + createdAt = LocalDateTime.of(2026, 1, 1, 1, 0) + updatedAt = LocalDateTime.of(2026, 1, 1, 1, 0) + routeNameFull = "OLD-ROUTE" + } + val routePassportRepository = mock(RoutePassportRepository::class.java) + val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java) + val savedRoutes = mutableListOf() + val service = service(routePassportRepository, routePassportOutboxRepository) + + doReturn(Optional.of(existingEntity)) + .`when`(routePassportRepository) + .findById(fixture.routeId) + doReturn(true) + .`when`(routePassportOutboxRepository) + .existsByRouteIdAndEventType(fixture.routeId, PcpKafkaEvent.ModeStatusChangedEvent.name) + doReturn(true) + .`when`(routePassportOutboxRepository) + .existsByRouteIdAndEventType(fixture.routeId, PcpKafkaEvent.RouteGeoRefEvent.name) + captureRouteSaves(routePassportRepository, savedRoutes) + + service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord) + + assertEquals(1, savedRoutes.size) + assertEquals("ROUTE-001", existingEntity.routeNameFull) + assertEquals(LocalDateTime.of(2026, 1, 1, 1, 0), existingEntity.createdAt) + assertTrue(existingEntity.updatedAt.isAfter(existingEntity.createdAt)) + verify(routePassportOutboxRepository, never()).save(anyRoutePassportOutboxEntity()) + } + + @Test + fun `ingest stores source record metadata in route passport`() { + val fixture = fixture() + val routePassportRepository = mock(RoutePassportRepository::class.java) + val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java) + val savedRoutes = mutableListOf() + val service = service(routePassportRepository, routePassportOutboxRepository) + + doReturn(Optional.empty()) + .`when`(routePassportRepository) + .findById(fixture.routeId) + captureRouteSaves(routePassportRepository, savedRoutes) + + service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord) + + val routeEntity = savedRoutes.single() + assertEquals("pcp.request.survey-georeference.v1", routeEntity.sourceTopic) + assertEquals(3, routeEntity.sourcePartition) + assertEquals(42L, routeEntity.sourceOffset) + assertEquals("source-key-1", routeEntity.sourceKey) + assertEquals(objectMapper.readTree(fixture.rawPayload), routeEntity.rawPayload) + assertEquals("trace-1", routeEntity.traceId) + assertEquals("corr-1", routeEntity.correlationId) + } + + @Test + fun `outbox payload keeps downstream kafka message contract`() { + val fixture = fixture() + val routePassportRepository = mock(RoutePassportRepository::class.java) + val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java) + val savedOutbox = mutableListOf() + val service = service(routePassportRepository, routePassportOutboxRepository) + + doReturn(Optional.empty()) + .`when`(routePassportRepository) + .findById(fixture.routeId) + captureRouteSaves(routePassportRepository, mutableListOf()) + captureOutboxSaves(routePassportOutboxRepository, savedOutbox) + + service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord) + + val modeStatusPayload = savedOutbox.first().payload + assertTrue(modeStatusPayload.isObject) + assertEquals(PcpKafkaEvent.ModeStatusChangedEvent.name, modeStatusPayload.path("type").stringValue()) + assertEquals("trace-1", modeStatusPayload.path("traceId").stringValue()) + assertEquals("corr-1", modeStatusPayload.path("correlationId").stringValue()) + assertEquals("route-processing", modeStatusPayload.path("source").stringValue()) + assertEquals("pcp", modeStatusPayload.path("group").stringValue()) + assertTrue(modeStatusPayload.path("data").isObject) + assertEquals(fixture.routeId.toString(), modeStatusPayload.path("data").path("routeId").stringValue()) + assertEquals("ROUTE-001", modeStatusPayload.path("data").path("routeNameFull").stringValue()) + } + + private fun service( + routePassportRepository: RoutePassportRepository, + routePassportOutboxRepository: RoutePassportOutboxRepository, + ): RoutePassportIngestionService = + RoutePassportIngestionService( + routePassportRepository = routePassportRepository, + routePassportOutboxRepository = routePassportOutboxRepository, + objectMapper = objectMapper, + kafkaTopicsProperties = kafkaTopicsProperties, + ) + + private fun captureRouteSaves( + routePassportRepository: RoutePassportRepository, + savedRoutes: MutableList, + ) { + doAnswer { invocation -> + val entity = invocation.arguments[0] as RoutePassportEntity + savedRoutes += entity + entity + }.`when`(routePassportRepository).save(anyRoutePassportEntity()) + } + + private fun captureOutboxSaves( + routePassportOutboxRepository: RoutePassportOutboxRepository, + savedOutbox: MutableList, + ) { + doAnswer { invocation -> + val entity = invocation.arguments[0] as RoutePassportOutboxEntity + savedOutbox += entity + entity + }.`when`(routePassportOutboxRepository).save(anyRoutePassportOutboxEntity()) + } + + private fun anyRoutePassportEntity(): RoutePassportEntity = + any(RoutePassportEntity::class.java) ?: existingRouteEntity(UUID.randomUUID()) + + private fun anyRoutePassportOutboxEntity(): RoutePassportOutboxEntity = + any(RoutePassportOutboxEntity::class.java) ?: RoutePassportOutboxEntity( + id = UUID.randomUUID(), + routeId = UUID.randomUUID(), + eventType = PcpKafkaEvent.ModeStatusChangedEvent.name, + topic = "topic", + messageKey = "key", + payload = objectMapper.readTree("{}"), + status = RoutePassportOutboxStatus.PENDING, + attempts = 0, + maxAttempts = 10, + nextAttemptAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now(), + ) + + private fun fixture(): IngestionFixture { + val routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03") + val routePassportDto = RoutePassportDto( + routeId = routeId, + kaShort = "S1A", + kaFull = "sentinel-1a", + routeNameFull = "ROUTE-001", + routeNameShort = "S1A-GRDH", + orbitNumber = 59796L, + orbitState = "Descending", + intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0), + intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30), + rollAngle = AngleRangeDto( + min = BigDecimal("-15.5"), + max = BigDecimal("-12.0"), + ), + visirAngle = AngleRangeDto( + min = BigDecimal("21.0"), + max = BigDecimal("24.5"), + ), + resolutionRange = BigDecimal("10.0"), + resolutionAzimuth = BigDecimal("10.0"), + polarisation = listOf("VH", "VV"), + processingLevel = "GRD", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))", + ) + + return IngestionFixture( + routeId = routeId, + parsedMessage = ParsedRoutePassportMessage( + metadata = RoutePassportMessageMetadata( + traceId = "trace-1", + correlationId = "corr-1", + ), + routePassportDto = routePassportDto, + ), + rawPayload = """{"data":{"id":"ROUTE-001"}}""", + sourceRecord = RoutePassportSourceRecordMetadata( + topic = "pcp.request.survey-georeference.v1", + partition = 3, + offset = 42L, + key = "source-key-1", + ), + ) + } + + private fun existingRouteEntity(routeId: UUID): RoutePassportEntity = + RoutePassportEntity( + routeId = routeId, + routeNameFull = "ROUTE-OLD", + routeNameShort = "OLD", + kaShort = "S1A", + kaFull = "sentinel-1a", + orbitNumber = 1L, + orbitState = "Descending", + intervalBegin = LocalDateTime.of(2026, 1, 1, 0, 0), + intervalEnd = LocalDateTime.of(2026, 1, 1, 0, 1), + rollAngleMin = BigDecimal.ZERO, + rollAngleMax = BigDecimal.ONE, + visirAngleMin = BigDecimal.ZERO, + visirAngleMax = BigDecimal.ONE, + resolutionRange = BigDecimal.TEN, + resolutionAzimuth = BigDecimal.TEN, + polarisation = objectMapper.readTree("""["VH"]"""), + processingLevel = "GRD", + geometryWkt = "POLYGON((0 0, 1 0, 1 1, 0 0))", + routeStatus = "PROCESSED", + createdAt = LocalDateTime.of(2026, 1, 1, 0, 0), + updatedAt = LocalDateTime.of(2026, 1, 1, 0, 0), + ) + + private data class IngestionFixture( + val routeId: UUID, + val parsedMessage: ParsedRoutePassportMessage, + val rawPayload: String, + val sourceRecord: RoutePassportSourceRecordMetadata, + ) +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserServiceTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserServiceTest.kt new file mode 100644 index 0000000..a977ba3 --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportJsonParserServiceTest.kt @@ -0,0 +1,198 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException +import java.util.UUID +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.json.JsonMapper +import tools.jackson.module.kotlin.KotlinModule +import java.nio.charset.StandardCharsets + +/** + * Тест сервиса парсинга JSON паспорта маршрута. + */ +class RoutePassportJsonParserServiceTest { + private val objectMapper: ObjectMapper = JsonMapper.builder() + .addModule(KotlinModule.Builder().build()) + .build() + + @Test + fun `should parse envelope passport json into dto`() { + val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter()) + + val json: String = this::class.java.classLoader + .getResourceAsStream("test-passport-message.json") + ?.readAllBytes() + ?.toString(StandardCharsets.UTF_8) + ?: error("Не найден файл test-passport-message.json в classpath") + + val parsedMessage = parserService.parse(json) + val dto = parsedMessage.routePassportDto + + assertEquals("c0dd08ea-dd64-4633-8845-d1b163bdf506", parsedMessage.metadata.traceId) + assertEquals("db605d5f-77e9-45b4-82cc-709837306257", parsedMessage.metadata.correlationId) + assertEquals(UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"), dto.routeId) + assertEquals("S1A", dto.kaShort) + assertEquals("sentinel-1a", dto.kaFull) + assertEquals("S1A-GRDH", dto.routeNameShort) + assertEquals( + "S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE", + dto.routeNameFull, + ) + assertEquals(59796L, dto.orbitNumber) + assertEquals("Descending", dto.orbitState) + assertEquals("GRD", dto.processingLevel) + assertEquals(SurveyModeStatus.PROCESSED, dto.routeStatus) + assertEquals(listOf("VH", "VV"), dto.polarisation) + assertEquals( + "POLYGON((67.675827 38.114815, 64.779434 38.514816, 65.094017 40.015545, 68.05294 39.616795, 67.675827 38.114815))", + dto.geometry, + ) + } + + @Test + fun `should generate route id when raw payload does not contain it`() { + val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter()) + + val json = """ + { + "traceid": "trace-1", + "correlationid": "corr-1", + "data": { + "id": "ROUTE-001", + "collection": "S1A-GRDH", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]] + }, + "properties": { + "platform": "sentinel-1a", + "missionId": "S1A", + "route:orbit_number": "59796", + "sat:orbit_state": "Descending", + "intervalBegin": "2025-06-25T01:38:54.481190", + "intervalEnd": "2025-06-25T01:39:19.479675", + "rollAngle": [-5.0, 12.0], + "visirAngle": [30.0, 46.0], + "resolutionRange": "1.000000e+01", + "resolutionAzimuth": "1.000000e+01", + "polarisation": ["VH", "VV"], + "processing:level": "GRD" + } + } + } + """.trimIndent() + + val parsedMessage = parserService.parse(json) + + assertEquals( + UUID.nameUUIDFromBytes("ROUTE-001".toByteArray(StandardCharsets.UTF_8)), + parsedMessage.routePassportDto.routeId, + ) + } + + @Test + fun `should parse trace_id alias without changing correlationid`() { + val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter()) + + val json = """ + { + "trace_id": "trace-aliased", + "correlationid": "corr-aliased", + "data": { + "id": "ROUTE-001", + "collection": "S1A-GRDH", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]] + }, + "properties": { + "platform": "sentinel-1a", + "missionId": "S1A", + "route:orbit_number": "59796", + "sat:orbit_state": "Descending", + "intervalBegin": "2025-06-25T01:38:54.481190", + "intervalEnd": "2025-06-25T01:39:19.479675", + "rollAngle": [-5.0, 12.0], + "visirAngle": [30.0, 46.0], + "resolutionRange": "1.000000e+01", + "resolutionAzimuth": "1.000000e+01", + "polarisation": ["VH", "VV"], + "processing:level": "GRD" + } + } + } + """.trimIndent() + + val parsedMessage = parserService.parse(json) + + assertEquals("trace-aliased", parsedMessage.metadata.traceId) + assertEquals("corr-aliased", parsedMessage.metadata.correlationId) + } + + @Test + fun `should take geometry from converter`() { + val routeGeometryConverter = StubRouteGeometryConverter() + val parserService = RoutePassportJsonParserService(objectMapper, routeGeometryConverter) + + val parsedMessage = parserService.parse(minimalJson("Polygon", "[[[0, 0], [1, 0], [1, 1], [0, 0]]]")) + + assertEquals("POLYGON((9 9, 10 9, 10 10, 9 9))", parsedMessage.routePassportDto.geometry) + assertEquals(1, routeGeometryConverter.callCount) + } + + @Test + fun `should reject unsupported geometry type in parser path`() { + val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter()) + + assertThrows(RoutePassportContractException::class.java) { + parserService.parse(minimalJson("Point", "[0, 0]")) + } + } + + private fun minimalJson( + geometryType: String, + coordinates: String, + ): String = + """ + { + "traceid": "trace-1", + "correlationid": "corr-1", + "data": { + "id": "ROUTE-001", + "collection": "S1A-GRDH", + "geometry": { + "type": "$geometryType", + "coordinates": $coordinates + }, + "properties": { + "platform": "sentinel-1a", + "missionId": "S1A", + "route:orbit_number": "59796", + "sat:orbit_state": "Descending", + "intervalBegin": "2025-06-25T01:38:54.481190", + "intervalEnd": "2025-06-25T01:39:19.479675", + "rollAngle": [-5.0, 12.0], + "visirAngle": [30.0, 46.0], + "resolutionRange": "1.000000e+01", + "resolutionAzimuth": "1.000000e+01", + "polarisation": ["VH", "VV"], + "processing:level": "GRD" + } + } + } + """.trimIndent() + + private class StubRouteGeometryConverter : RouteGeometryConverter() { + var callCount: Int = 0 + + override fun convert(geometry: RawGeoJsonGeometryDto?): String { + callCount += 1 + return "POLYGON((9 9, 10 9, 10 10, 9 9))" + } + } +} diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherServiceTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherServiceTest.kt new file mode 100644 index 0000000..7e550a4 --- /dev/null +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportKafkaPublisherServiceTest.kt @@ -0,0 +1,144 @@ +package space.nstart.pcp.routepassportconsumer.service + +import org.apache.kafka.clients.producer.ProducerRecord +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.SendResult +import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties +import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto +import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto +import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.CompletableFuture +import tools.jackson.databind.ObjectMapper + +class RoutePassportKafkaPublisherServiceTest { + + private fun anyProducerRecord(): ProducerRecord { + @Suppress("UNCHECKED_CAST") + return (any(ProducerRecord::class.java) as ProducerRecord?) + ?: ProducerRecord("stub-topic", "stub-key", "stub-payload") + } + + private fun captureProducerRecord( + captor: ArgumentCaptor>, + ): ProducerRecord { + return captor.capture() ?: ProducerRecord("stub-topic", "stub-key", "stub-payload") + } + + @Test + fun `should publish kafka message to both output topics with dedicated event types`() { + val objectMapper = mock(ObjectMapper::class.java) + @Suppress("UNCHECKED_CAST") + val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + val kafkaTopicsProperties = KafkaTopicsProperties( + input = "pcp.request.survey-georeference.v1", + processedRoute = "pcp.route.in.v1", + routeGeoreference = "pcp.route.georeference.v1", + ) + val publisherService = RoutePassportKafkaPublisherService( + objectMapper = objectMapper, + kafkaTemplate = kafkaTemplate, + kafkaTopicsProperties = kafkaTopicsProperties, + ) + val parsedMessage = ParsedRoutePassportMessage( + metadata = RoutePassportMessageMetadata( + traceId = "trace-1", + correlationId = "corr-1", + ), + routePassportDto = RoutePassportDto( + routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"), + kaShort = "S1A", + kaFull = "sentinel-1a", + routeNameFull = "ROUTE-001", + routeNameShort = "S1A-GRDH", + orbitNumber = 59796L, + orbitState = "Descending", + intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0), + intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30), + rollAngle = AngleRangeDto( + min = BigDecimal("-15.5"), + max = BigDecimal("-12.0"), + ), + visirAngle = AngleRangeDto( + min = BigDecimal("21.0"), + max = BigDecimal("24.5"), + ), + resolutionRange = BigDecimal("10.0"), + resolutionAzimuth = BigDecimal("10.0"), + polarisation = listOf("VH", "VV"), + processingLevel = "GRD", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))", + ), + ) + val sendResult = mock(SendResult::class.java) as SendResult + + doAnswer { invocation -> + val message = invocation.arguments[0] as KafkaMessage<*> + """{"type":"${message.type.enumCast()?.name}","routeNameFull":"${(message.data as RoutePassportDto).routeNameFull}"}""" + }.`when`(objectMapper).writeValueAsString(any()) + doReturn(CompletableFuture.completedFuture(sendResult)) + .`when`(kafkaTemplate) + .send(anyProducerRecord()) + + publisherService.publish(parsedMessage) + + @Suppress("UNCHECKED_CAST") + val recordCaptor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor> + @Suppress("UNCHECKED_CAST") + val messageCaptor = ArgumentCaptor.forClass(KafkaMessage::class.java) as ArgumentCaptor> + + verify(objectMapper, times(2)).writeValueAsString(messageCaptor.capture()) + verify(kafkaTemplate, times(2)).send(captureProducerRecord(recordCaptor)) + + kotlin.test.assertEquals( + listOf(PcpKafkaEvent.ModeStatusChangedEvent, PcpKafkaEvent.RouteGeoRefEvent), + messageCaptor.allValues.map { it.type }, + ) + messageCaptor.allValues.forEach { message -> + kotlin.test.assertEquals("trace-1", message.traceId) + kotlin.test.assertEquals("corr-1", message.correlationId) + kotlin.test.assertEquals("pcp", message.group) + kotlin.test.assertEquals("route-processing", message.source) + kotlin.test.assertEquals("application/json", message.dataContentType) + kotlin.test.assertEquals("1.0", message.specVersion) + kotlin.test.assertEquals(parsedMessage.routePassportDto, message.data) + kotlin.test.assertTrue(message.id.isNotBlank()) + kotlin.test.assertNotNull(message.time) + } + kotlin.test.assertEquals( + listOf("pcp.route.in.v1", "pcp.route.georeference.v1"), + recordCaptor.allValues.map { it.topic() }, + ) + kotlin.test.assertEquals( + listOf("trace-1", "trace-1"), + recordCaptor.allValues.map { it.key() }, + ) + kotlin.test.assertEquals( + listOf( + """{"type":"ModeStatusChangedEvent","routeNameFull":"ROUTE-001"}""", + """{"type":"RouteGeoRefEvent","routeNameFull":"ROUTE-001"}""", + ), + recordCaptor.allValues.map { it.value() }, + ) + kotlin.test.assertEquals( + listOf("ModeStatusChangedEvent", "RouteGeoRefEvent"), + recordCaptor.allValues.map { + it.headers().lastHeader("type").value().toString(StandardCharsets.UTF_8) + }, + ) + } +} diff --git a/services/pcp-route-processing-service/src/test/resources/passport_example.json b/services/pcp-route-processing-service/src/test/resources/passport_example.json new file mode 100644 index 0000000..2c3e23f --- /dev/null +++ b/services/pcp-route-processing-service/src/test/resources/passport_example.json @@ -0,0 +1 @@ +{"type":"Feature","stac_version":"1.0.0","stac_extensions":null,"id":"S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE","collection":"S1A-GRDH","geometry":"POLYGON((67.675827 38.114815, 64.779434 38.514816, 65.094017 40.015545, 68.05294 39.616795, 67.675827 38.114815))","bbox":[64.779434,40.015545,68.05294,38.114815],"properties":{"datetime":"2026-03-31T12:08:30.570573Z","created":null,"updated":"2026-03-31T12:08:30.610660Z","platform":"sentinel-1a","instruments":null,"processing:level":"GRD","route:orbit_number":"59796","missionId":"S1A","sat:orbit_state":"Descending","polarisation":["VH","VV"],"rollAngle":[30.07864486915724,30.09378936608302],"visirAngle":[30.63331560676386,46.07086702096005],"intervalBegin":"2025-06-25T01:38:54.481190","intervalEnd":"2025-06-25T01:39:19.479675","resolutionRange":"1.000000e+01","resolutionAzimuth":"1.000000e+01"},"links":[{"rel":"self","type":"application/geo+json","href":"http://192.168.100.160:8085/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE"},{"rel":"parent","type":"application/json","href":"http://192.168.100.160:8085/collections/S1A-GRDH"},{"rel":"collection","type":"application/json","href":"http://192.168.100.160:8085/collections/S1A-GRDH"},{"rel":"root","type":"application/json","href":"http://192.168.100.160:8085/"}],"assets":{"schema-calibration-vh":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-calibration-vv":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-noise-vh":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-noise-vv":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-product-vh":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-product-vv":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"safe-manifest":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53","type":"application/xml","title":"safe","description":null,"roles":["metadata"]},"vh":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648","type":"image/tiff; application=geotiff; profile=cloud-optimized","title":"tif","description":null,"roles":["visual"]},"vv":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb","type":"image/tiff; application=geotiff; profile=cloud-optimized","title":"tif","description":null,"roles":["visual"]},"kml":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54","type":"application/kml","title":"kml","description":null,"roles":["metadata"]},"thumbnail":{"href":"http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0","type":"image/png","title":"png","description":null,"roles":["thumbnail","overview"]}}} diff --git a/services/pcp-satellite-catalog-service/Dockerfile b/services/pcp-satellite-catalog-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-satellite-catalog-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-satellite-catalog-service/build.gradle.kts b/services/pcp-satellite-catalog-service/build.gradle.kts new file mode 100644 index 0000000..b8e315f --- /dev/null +++ b/services/pcp-satellite-catalog-service/build.gradle.kts @@ -0,0 +1,65 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +version = "1.0.0" + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("org.postgresql:postgresql") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + testRuntimeOnly("com.h2database:h2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + useJUnitPlatform() + systemProperty("spring.profiles.active", "test") +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/PcpSatelliteCatalogServiceApplication.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/PcpSatelliteCatalogServiceApplication.kt new file mode 100644 index 0000000..b981228 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/PcpSatelliteCatalogServiceApplication.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_satellite_catalog_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class PcpSatelliteCatalogServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/GlobalExceptionHandler.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..6f4e569 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler + +class CustomValidationException(message: String) : RuntimeException(message) + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> = + ResponseEntity.badRequest().body(mapOf("error" to (ex.message ?: "validation error"))) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as? FieldError)?.field ?: "request" + errors[fieldName] = error.defaultMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/KafkaConfig.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/KafkaConfig.kt new file mode 100644 index 0000000..8852a31 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/KafkaConfig.kt @@ -0,0 +1,49 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.configuration + +import org.apache.kafka.clients.admin.AdminClientConfig +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaAdmin +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${app.kafka.topics.satellites:pcp.satellites}") + private lateinit var satelliteEventsTopicName: String + + @Bean + fun satelliteEventsTopic(): NewTopic = NewTopic(satelliteEventsTopicName, 1, 1) + + @Bean + fun kafkaAdmin(): KafkaAdmin = + KafkaAdmin( + mutableMapOf( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers + ) + ) + + @Bean + fun producerFactory(): ProducerFactory = + DefaultKafkaProducerFactory( + mutableMapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java + ) + ) + + @Bean + fun kafkaTemplate(): KafkaTemplate = KafkaTemplate(producerFactory()) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/ValidationConfig.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/ValidationConfig.kt new file mode 100644 index 0000000..ef688a0 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/configuration/ValidationConfig.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator = Validation.buildDefaultValidatorFactory().validator +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteCatalogController.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteCatalogController.kt new file mode 100644 index 0000000..8412a59 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteCatalogController.kt @@ -0,0 +1,100 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.controller + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +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_satellite_catalog_service.service.SatelliteCatalogService +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO + +@RestController +@RequestMapping("/api/satellites") +class SatelliteCatalogController( + private val satelliteCatalogService: SatelliteCatalogService +) { + + @GetMapping + fun all() = satelliteCatalogService.all() + + @GetMapping("/{satellite_id}") + fun byId(@PathVariable("satellite_id") satelliteId: Long) = + satelliteCatalogService.byId(satelliteId) + + @GetMapping("/by-norad/{norad_id}") + fun byNoradId(@PathVariable("norad_id") noradId: Long) = + satelliteCatalogService.byNoradId(noradId) + + @PostMapping + fun create(@Valid @RequestBody request: SatelliteCreateDTO) = + satelliteCatalogService.createSatellite(request) + + @PutMapping("/{satellite_id}") + fun update( + @PathVariable("satellite_id") satelliteId: Long, + @Valid @RequestBody request: SatelliteUpdateDTO + ) = satelliteCatalogService.updateSatellite(satelliteId, request) + + @DeleteMapping("/{satellite_id}") + fun delete(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity { + satelliteCatalogService.deleteSatellite(satelliteId) + return ResponseEntity.noContent().build() + } + + @GetMapping("/{satellite_id}/observation-profile") + fun observationProfile(@PathVariable("satellite_id") satelliteId: Long) = + satelliteCatalogService.observationProfile(satelliteId) + + @PostMapping("/{satellite_id}/observation-profile") + fun createObservationProfile( + @PathVariable("satellite_id") satelliteId: Long, + @Valid @RequestBody request: SatelliteObservationProfileDTO + ) = satelliteCatalogService.createObservationProfile(satelliteId, request) + + @PutMapping("/{satellite_id}/observation-profile") + fun updateObservationProfile( + @PathVariable("satellite_id") satelliteId: Long, + @Valid @RequestBody request: SatelliteObservationProfileDTO + ) = satelliteCatalogService.updateObservationProfile(satelliteId, request) + + @DeleteMapping("/{satellite_id}/observation-profile") + fun deleteObservationProfile(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity { + satelliteCatalogService.deleteObservationProfile(satelliteId) + return ResponseEntity.noContent().build() + } + + @GetMapping("/{satellite_id}/slot-profile") + fun slotProfile(@PathVariable("satellite_id") satelliteId: Long) = + satelliteCatalogService.slotProfile(satelliteId) + + @PostMapping("/{satellite_id}/slot-profile") + fun createSlotProfile( + @PathVariable("satellite_id") satelliteId: Long, + @Valid @RequestBody request: SatelliteSlotProfileDTO + ) = satelliteCatalogService.createSlotProfile(satelliteId, request) + + @PutMapping("/{satellite_id}/slot-profile") + fun updateSlotProfile( + @PathVariable("satellite_id") satelliteId: Long, + @Valid @RequestBody request: SatelliteSlotProfileDTO + ) = satelliteCatalogService.updateSlotProfile(satelliteId, request) + + @DeleteMapping("/{satellite_id}/slot-profile") + fun deleteSlotProfile(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity { + satelliteCatalogService.deleteSlotProfile(satelliteId) + return ResponseEntity.noContent().build() + } + + @PostMapping("/batch") + fun batch(@Valid @RequestBody request: SatelliteBatchRequestDTO) = + satelliteCatalogService.batch(request) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteGroupController.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteGroupController.kt new file mode 100644 index 0000000..b4b2890 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteGroupController.kt @@ -0,0 +1,45 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.controller + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +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_satellite_catalog_service.service.SatelliteCatalogService +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO + +@RestController +@RequestMapping("/api/satellite-groups") +class SatelliteGroupController( + private val satelliteCatalogService: SatelliteCatalogService +) { + + @GetMapping + fun all() = satelliteCatalogService.allGroups() + + @GetMapping("/{group_id}") + fun byId(@PathVariable("group_id") groupId: Long) = + satelliteCatalogService.groupById(groupId) + + @PostMapping + fun create(@Valid @RequestBody request: SatelliteGroupCreateDTO) = + satelliteCatalogService.createGroup(request) + + @PutMapping("/{group_id}") + fun update( + @PathVariable("group_id") groupId: Long, + @Valid @RequestBody request: SatelliteGroupUpdateDTO + ) = satelliteCatalogService.updateGroup(groupId, request) + + @DeleteMapping("/{group_id}") + fun delete(@PathVariable("group_id") groupId: Long): ResponseEntity { + satelliteCatalogService.deleteGroup(groupId) + return ResponseEntity.noContent().build() + } +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteEntity.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteEntity.kt new file mode 100644 index 0000000..3de9a80 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteEntity.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO + +@Entity +@Table(name = "satellite") +class SatelliteEntity( + @Id + val id: Long = 0, + @Column(name = "norad_id") + val noradId: Long? = null, + @Column(nullable = false) + val code: String = "", + @Column(nullable = false) + val name: String = "", + @Column(name = "type_code", nullable = false) + val typeCode: String = "", + @Column(nullable = false) + val active: Boolean = true, + @Column(name = "scan_tle", nullable = false) + val scanTle: Boolean = false, + @Column(nullable = false) + val red: Short = 255, + @Column(nullable = false) + val green: Short = 0, + @Column(nullable = false) + val blue: Short = 0 +) { + fun toSummaryDTO() = SatelliteSummaryDTO( + id = id, + noradId = noradId, + code = code, + name = name, + typeCode = typeCode, + active = active, + scanTle = scanTle, + visualization = SatelliteVisualizationDTO( + red = red, + green = green, + blue = blue + ) + ) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteGroupEntity.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteGroupEntity.kt new file mode 100644 index 0000000..f63b35b --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteGroupEntity.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "satellite_group") +class SatelliteGroupEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + @Column(nullable = false) + val name: String = "" +) diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteGroupSatelliteEntity.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteGroupSatelliteEntity.kt new file mode 100644 index 0000000..1c9c101 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteGroupSatelliteEntity.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.IdClass +import jakarta.persistence.Table +import java.io.Serializable + +@Entity +@Table(name = "satellite_group_satellite") +@IdClass(SatelliteGroupSatelliteId::class) +class SatelliteGroupSatelliteEntity( + @Id + @Column(name = "group_id") + val groupId: Long = 0, + @Id + @Column(name = "satellite_id") + val satelliteId: Long = 0 +) + +data class SatelliteGroupSatelliteId( + val groupId: Long = 0, + val satelliteId: Long = 0 +) : Serializable diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteObservationProfileEntity.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteObservationProfileEntity.kt new file mode 100644 index 0000000..14a9dd0 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteObservationProfileEntity.kt @@ -0,0 +1,39 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO + +@Entity +@Table(name = "satellite_observation_profile") +class SatelliteObservationProfileEntity( + @Id + @Column(name = "satellite_id") + val satelliteId: Long = 0, + @Column(name = "capture_angle", nullable = false) + val captureAngle: Double = 1.5, + @Column(name = "sun_angle_min", nullable = false) + val sunAngleMin: Double = -90.0, + @Column(name = "duration_min_seconds", nullable = false) + val durationMinSeconds: Long = 10, + @Column(name = "duration_max_seconds", nullable = false) + val durationMaxSeconds: Long = 300, + @Column(name = "mmi_seconds", nullable = false) + val mmiSeconds: Long = 10, + @Column(name = "daily_max_duration_seconds", nullable = false) + val dailyMaxDurationSeconds: Long = 35 * 60, + @Column(name = "revolution_max_duration_seconds", nullable = false) + val revolutionMaxDurationSeconds: Long = 5 * 60 +) { + fun toDTO() = SatelliteObservationProfileDTO( + captureAngle = captureAngle, + sunAngleMin = sunAngleMin, + durationMinSeconds = durationMinSeconds, + durationMaxSeconds = durationMaxSeconds, + mmiSeconds = mmiSeconds, + dailyMaxDurationSeconds = dailyMaxDurationSeconds, + revolutionMaxDurationSeconds = revolutionMaxDurationSeconds + ) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteSlotAngleEntity.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteSlotAngleEntity.kt new file mode 100644 index 0000000..41d65e7 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteSlotAngleEntity.kt @@ -0,0 +1,29 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.IdClass +import jakarta.persistence.Table +import java.io.Serializable + +@Entity +@Table(name = "satellite_slot_angle") +@IdClass(SatelliteSlotAngleId::class) +class SatelliteSlotAngleEntity( + @Id + @Column(name = "satellite_id") + val satelliteId: Long = 0, + @Id + @Column(name = "position") + val position: Int = 0, + @Column(nullable = false) + val angleBegin: Double = 0.0, + @Column(nullable = false) + val angleEnd: Double = 0.0 +) + +data class SatelliteSlotAngleId( + val satelliteId: Long = 0, + val position: Int = 0 +) : Serializable diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteSlotProfileEntity.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteSlotProfileEntity.kt new file mode 100644 index 0000000..87480e6 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/entity/SatelliteSlotProfileEntity.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "satellite_slot_profile") +class SatelliteSlotProfileEntity( + @Id + @Column(name = "satellite_id") + val satelliteId: Long = 0, + @Column(name = "cycle_revs", nullable = false) + val cycleRevs: Long = 0, + @Column(name = "tn_calc") + val tnCalc: LocalDateTime? = null, + @Column(name = "slot_duration", nullable = false) + val slotDuration: Long = 10, + @Column(name = "duration_calc_days", nullable = false) + val durationCalcDays: Long = 0, + @Column(name = "max_chain_length", nullable = false) + val maxChainLength: Int = 0 +) diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteGroupRepository.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteGroupRepository.kt new file mode 100644 index 0000000..06d4066 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteGroupRepository.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupEntity + +interface SatelliteGroupRepository : JpaRepository { + fun findAllByOrderByIdAsc(): List + fun existsByName(name: String): Boolean + fun existsByNameAndIdNot(name: String, id: Long): Boolean +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteGroupSatelliteRepository.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteGroupSatelliteRepository.kt new file mode 100644 index 0000000..24bf297 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteGroupSatelliteRepository.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupSatelliteEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupSatelliteId + +interface SatelliteGroupSatelliteRepository : JpaRepository { + fun findAllByGroupIdInOrderByGroupIdAscSatelliteIdAsc(groupIds: Collection): List + fun deleteAllByGroupId(groupId: Long) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteObservationProfileRepository.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteObservationProfileRepository.kt new file mode 100644 index 0000000..67d8863 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteObservationProfileRepository.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteObservationProfileEntity + +interface SatelliteObservationProfileRepository : JpaRepository { + fun findAllBySatelliteIdIn(satelliteIds: Collection): List +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteRepository.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteRepository.kt new file mode 100644 index 0000000..6838f3a --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteRepository.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteEntity + +interface SatelliteRepository : JpaRepository { + fun findAllByOrderByIdAsc(): List + fun findByNoradId(noradId: Long): SatelliteEntity? + fun findAllByIdIn(ids: Collection): List + fun existsByNoradId(noradId: Long): Boolean + fun existsByNoradIdAndIdNot(noradId: Long, id: Long): Boolean + fun existsByCode(code: String): Boolean + fun existsByCodeAndIdNot(code: String, id: Long): Boolean +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteSlotAngleRepository.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteSlotAngleRepository.kt new file mode 100644 index 0000000..7883983 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteSlotAngleRepository.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotAngleEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotAngleId + +interface SatelliteSlotAngleRepository : JpaRepository { + fun findAllBySatelliteIdInOrderBySatelliteIdAscPositionAsc(satelliteIds: Collection): List + fun deleteAllBySatelliteId(satelliteId: Long) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteSlotProfileRepository.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteSlotProfileRepository.kt new file mode 100644 index 0000000..4b1b562 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/repository/SatelliteSlotProfileRepository.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotProfileEntity + +interface SatelliteSlotProfileRepository : JpaRepository { + fun findAllBySatelliteIdIn(satelliteIds: Collection): List +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/service/SatelliteCatalogService.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/service/SatelliteCatalogService.kt new file mode 100644 index 0000000..40a0ac2 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/service/SatelliteCatalogService.kt @@ -0,0 +1,412 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import space.nstart.pcp.pcp_satellite_catalog_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteGroupSatelliteEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteObservationProfileEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotAngleEntity +import space.nstart.pcp.pcp_satellite_catalog_service.entity.SatelliteSlotProfileEntity +import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteGroupRepository +import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteGroupSatelliteRepository +import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteObservationProfileRepository +import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteRepository +import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteSlotAngleRepository +import space.nstart.pcp.pcp_satellite_catalog_service.repository.SatelliteSlotProfileRepository +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +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.SatelliteGroupUpdateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO + +@Service +class SatelliteCatalogService( + private val satelliteRepository: SatelliteRepository, + private val observationProfileRepository: SatelliteObservationProfileRepository, + private val slotProfileRepository: SatelliteSlotProfileRepository, + private val slotAngleRepository: SatelliteSlotAngleRepository, + private val satelliteGroupRepository: SatelliteGroupRepository, + private val satelliteGroupSatelliteRepository: SatelliteGroupSatelliteRepository, + private val satelliteDeletedKafkaPublisher: SatelliteDeletedKafkaPublisher +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + fun all(): List = + satelliteRepository.findAllByOrderByIdAsc().map { it.toSummaryDTO() } + + fun byId(id: Long): SatelliteDTO = + assembleSatellites(listOf(requireSatellite(id))).getValue(id) + + fun byNoradId(noradId: Long): SatelliteDTO { + val satellite = satelliteRepository.findByNoradId(noradId) + ?: throw CustomValidationException("Satellite with NORAD ID $noradId not found") + return assembleSatellites(listOf(satellite)).getValue(satellite.id) + } + + fun observationProfile(id: Long): SatelliteObservationProfileDTO { + requireSatellite(id) + return observationProfileRepository.findById(id).orElseThrow { + CustomValidationException("Observation profile for satellite $id not found") + }.toDTO() + } + + fun slotProfile(id: Long): SatelliteSlotProfileDTO { + requireSatellite(id) + return assembleSlotProfiles(listOf(id))[id] + ?: throw CustomValidationException("Slot profile for satellite $id not found") + } + + fun batch(request: SatelliteBatchRequestDTO): List { + val uniqueIds = request.ids.distinct() + if (uniqueIds.isEmpty()) { + return emptyList() + } + + val satellites = satelliteRepository.findAllByIdIn(uniqueIds) + val byId = assembleSatellites(satellites) + return uniqueIds.map { id -> + byId[id] ?: throw CustomValidationException("Satellite $id not found") + } + } + + @Transactional + fun createSatellite(request: SatelliteCreateDTO): SatelliteDTO { + validateSatelliteCreate(request) + satelliteRepository.save(request.toEntity()) + return byId(request.id) + } + + @Transactional + fun updateSatellite(id: Long, request: SatelliteUpdateDTO): SatelliteDTO { + requireSatellite(id) + validateSatelliteUpdate(id, request) + satelliteRepository.save(request.toEntity(id)) + return byId(id) + } + + @Transactional + fun deleteSatellite(id: Long) { + val satellite = requireSatellite(id) + val event = SatelliteDeletedEventDTO( + satelliteId = satellite.id, + noradId = satellite.noradId, + code = satellite.code, + name = satellite.name + ) + satelliteRepository.deleteById(id) + logger.info( + "Deleted satellite from catalog: satelliteId={}, noradId={}, code={}, name={}", + event.satelliteId, + event.noradId, + event.code, + event.name + ) + publishSatelliteDeletedAfterCommit(event) + } + + private fun publishSatelliteDeletedAfterCommit(event: SatelliteDeletedEventDTO) { + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + satelliteDeletedKafkaPublisher.publish(event) + } + } + ) + } + + @Transactional + fun createObservationProfile(id: Long, request: SatelliteObservationProfileDTO): SatelliteObservationProfileDTO { + requireSatellite(id) + if (observationProfileRepository.existsById(id)) { + throw CustomValidationException("Observation profile for satellite $id already exists") + } + return observationProfileRepository.save(request.toEntity(id)).toDTO() + } + + @Transactional + fun updateObservationProfile(id: Long, request: SatelliteObservationProfileDTO): SatelliteObservationProfileDTO { + requireSatellite(id) + if (!observationProfileRepository.existsById(id)) { + throw CustomValidationException("Observation profile for satellite $id not found") + } + return observationProfileRepository.save(request.toEntity(id)).toDTO() + } + + @Transactional + fun deleteObservationProfile(id: Long) { + requireSatellite(id) + if (!observationProfileRepository.existsById(id)) { + throw CustomValidationException("Observation profile for satellite $id not found") + } + observationProfileRepository.deleteById(id) + } + + @Transactional + fun createSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO { + requireSatellite(id) + if (slotProfileRepository.existsById(id)) { + throw CustomValidationException("Slot profile for satellite $id already exists") + } + return saveSlotProfile(id, request) + } + + @Transactional + fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO { + requireSatellite(id) + if (!slotProfileRepository.existsById(id)) { + throw CustomValidationException("Slot profile for satellite $id not found") + } + return saveSlotProfile(id, request) + } + + @Transactional + fun deleteSlotProfile(id: Long) { + requireSatellite(id) + if (!slotProfileRepository.existsById(id)) { + throw CustomValidationException("Slot profile for satellite $id not found") + } + slotAngleRepository.deleteAllBySatelliteId(id) + slotProfileRepository.deleteById(id) + } + + fun allGroups(): List = + assembleGroups(satelliteGroupRepository.findAllByOrderByIdAsc()).values.toList() + + fun groupById(id: Long): SatelliteGroupDTO = + assembleGroups(listOf(requireGroup(id))).getValue(id) + + @Transactional + fun createGroup(request: SatelliteGroupCreateDTO): SatelliteGroupDTO { + validateGroupCreate(request) + val satelliteIds = request.satelliteIds.distinct() + validateSatelliteIdsExist(satelliteIds) + val group = satelliteGroupRepository.save(SatelliteGroupEntity(name = request.name)) + saveGroupSatellites(group.id, satelliteIds) + return groupById(group.id) + } + + @Transactional + fun updateGroup(id: Long, request: SatelliteGroupUpdateDTO): SatelliteGroupDTO { + requireGroup(id) + validateGroupUpdate(id, request) + val satelliteIds = request.satelliteIds.distinct() + validateSatelliteIdsExist(satelliteIds) + satelliteGroupRepository.save(SatelliteGroupEntity(id = id, name = request.name)) + satelliteGroupSatelliteRepository.deleteAllByGroupId(id) + saveGroupSatellites(id, satelliteIds) + return groupById(id) + } + + @Transactional + fun deleteGroup(id: Long) { + requireGroup(id) + satelliteGroupRepository.deleteById(id) + } + + private fun saveSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO { + slotProfileRepository.save(request.toEntity(id)) + slotAngleRepository.deleteAllBySatelliteId(id) + slotAngleRepository.saveAll( + request.defaultAngles.mapIndexed { index, angle -> + SatelliteSlotAngleEntity( + satelliteId = id, + position = index + 1, + angleBegin = angle.angleBegin, + angleEnd = angle.angleEnd + ) + } + ) + return assembleSlotProfiles(listOf(id)).getValue(id) + } + + private fun saveGroupSatellites(groupId: Long, satelliteIds: List) { + if (satelliteIds.isEmpty()) { + return + } + satelliteGroupSatelliteRepository.saveAll( + satelliteIds.map { satelliteId -> + SatelliteGroupSatelliteEntity( + groupId = groupId, + satelliteId = satelliteId + ) + } + ) + } + + private fun validateSatelliteCreate(request: SatelliteCreateDTO) { + if (satelliteRepository.existsById(request.id)) { + throw CustomValidationException("Satellite ${request.id} already exists") + } + if (satelliteRepository.existsByCode(request.code)) { + throw CustomValidationException("Satellite code ${request.code} already exists") + } + request.noradId?.let { noradId -> + if (satelliteRepository.existsByNoradId(noradId)) { + throw CustomValidationException("Satellite with NORAD ID $noradId already exists") + } + } + } + + private fun validateSatelliteUpdate(id: Long, request: SatelliteUpdateDTO) { + if (satelliteRepository.existsByCodeAndIdNot(request.code, id)) { + throw CustomValidationException("Satellite code ${request.code} already exists") + } + request.noradId?.let { noradId -> + if (satelliteRepository.existsByNoradIdAndIdNot(noradId, id)) { + throw CustomValidationException("Satellite with NORAD ID $noradId already exists") + } + } + } + + private fun validateGroupCreate(request: SatelliteGroupCreateDTO) { + if (satelliteGroupRepository.existsByName(request.name)) { + throw CustomValidationException("Satellite group ${request.name} already exists") + } + } + + private fun validateGroupUpdate(id: Long, request: SatelliteGroupUpdateDTO) { + if (satelliteGroupRepository.existsByNameAndIdNot(request.name, id)) { + throw CustomValidationException("Satellite group ${request.name} already exists") + } + } + + private fun validateSatelliteIdsExist(ids: List) { + if (ids.isEmpty()) { + return + } + val existingIds = satelliteRepository.findAllByIdIn(ids).map { it.id }.toSet() + val missingIds = ids.filterNot(existingIds::contains) + if (missingIds.isNotEmpty()) { + throw CustomValidationException("Satellites not found: ${missingIds.joinToString(", ")}") + } + } + + private fun requireSatellite(id: Long): SatelliteEntity = + satelliteRepository.findById(id).orElseThrow { + CustomValidationException("Satellite $id not found") + } + + private fun requireGroup(id: Long): SatelliteGroupEntity = + satelliteGroupRepository.findById(id).orElseThrow { + CustomValidationException("Satellite group $id not found") + } + + private fun assembleSatellites(satellites: List): Map { + val ids = satellites.map { it.id } + val observationProfiles = observationProfileRepository.findAllBySatelliteIdIn(ids) + .associateBy { it.satelliteId } + val slotProfiles = assembleSlotProfiles(ids) + + return satellites.associate { satellite -> + val observationProfile = observationProfiles[satellite.id]?.toDTO() + satellite.id to SatelliteDTO( + id = satellite.id, + noradId = satellite.noradId, + code = satellite.code, + name = satellite.name, + typeCode = satellite.typeCode, + active = satellite.active, + scanTle = satellite.scanTle, + visualization = satellite.toSummaryDTO().visualization, + observationProfile = observationProfile, + slotProfile = slotProfiles[satellite.id] + ) + } + } + + private fun assembleSlotProfiles(ids: List): Map { + val slotProfiles = slotProfileRepository.findAllBySatelliteIdIn(ids) + .associateBy { it.satelliteId } + val anglesBySatelliteId = slotAngleRepository.findAllBySatelliteIdInOrderBySatelliteIdAscPositionAsc(ids) + .groupBy({ it.satelliteId }) { + SatelliteSlotAngleDTO( + angleBegin = it.angleBegin, + angleEnd = it.angleEnd + ) + } + + return slotProfiles.mapValues { (_, profile) -> + SatelliteSlotProfileDTO( + cycleRevs = profile.cycleRevs, + tnCalc = profile.tnCalc, + slotDuration = profile.slotDuration, + durationCalcDays = profile.durationCalcDays, + maxChainLength = profile.maxChainLength, + defaultAngles = anglesBySatelliteId[profile.satelliteId].orEmpty() + ) + } + } + + private fun assembleGroups(groups: List): Map { + val groupIds = groups.map { it.id } + val memberships = satelliteGroupSatelliteRepository.findAllByGroupIdInOrderByGroupIdAscSatelliteIdAsc(groupIds) + .groupBy({ it.groupId }, { it.satelliteId }) + + return groups.associate { group -> + group.id to SatelliteGroupDTO( + id = group.id, + name = group.name, + satelliteIds = memberships[group.id].orEmpty() + ) + } + } + + private fun SatelliteCreateDTO.toEntity() = SatelliteEntity( + id = id, + noradId = noradId, + code = code, + name = name, + typeCode = typeCode, + active = active, + scanTle = scanTle, + red = visualization.red, + green = visualization.green, + blue = visualization.blue + ) + + private fun SatelliteUpdateDTO.toEntity(id: Long) = SatelliteEntity( + id = id, + noradId = noradId, + code = code, + name = name, + typeCode = typeCode, + active = active, + scanTle = scanTle, + red = visualization.red, + green = visualization.green, + blue = visualization.blue + ) + + private fun SatelliteObservationProfileDTO.toEntity(satelliteId: Long) = SatelliteObservationProfileEntity( + satelliteId = satelliteId, + captureAngle = captureAngle, + sunAngleMin = sunAngleMin, + durationMinSeconds = durationMinSeconds, + durationMaxSeconds = durationMaxSeconds, + mmiSeconds = mmiSeconds, + dailyMaxDurationSeconds = dailyMaxDurationSeconds, + revolutionMaxDurationSeconds = revolutionMaxDurationSeconds + ) + + private fun SatelliteSlotProfileDTO.toEntity(satelliteId: Long) = SatelliteSlotProfileEntity( + satelliteId = satelliteId, + cycleRevs = cycleRevs, + tnCalc = tnCalc, + slotDuration = slotDuration, + durationCalcDays = durationCalcDays, + maxChainLength = maxChainLength + ) +} diff --git a/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/service/SatelliteDeletedKafkaPublisher.kt b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/service/SatelliteDeletedKafkaPublisher.kt new file mode 100644 index 0000000..39d1711 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/service/SatelliteDeletedKafkaPublisher.kt @@ -0,0 +1,69 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.service + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.nio.charset.StandardCharsets + +@Component +class SatelliteDeletedKafkaPublisher( + kafkaTemplateProvider: ObjectProvider>, + objectMapperProvider: ObjectProvider, + @param:Value("\${app.kafka.topics.satellites:pcp.satellites}") private val topic: String, + @param:Value("\${spring.application.name:pcp-satellite-catalog-service}") private val applicationName: String +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val kafkaTemplate = kafkaTemplateProvider.ifAvailable + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + fun publish(event: SatelliteDeletedEventDTO) { + val template = kafkaTemplate + if (template == null || topic.isBlank()) { + logger.warn( + "SatelliteDeletedEvent was not published because Kafka is not configured: satelliteId={}, noradId={}, topic={}", + event.satelliteId, + event.noradId, + topic + ) + return + } + + val message = KafkaMessage( + type = PcpKafkaEvent.SatelliteDeletedEvent, + data = event + ).apply { + source = applicationName + } + + val payload = try { + objectMapper.writeValueAsString(message) + } catch (exception: JsonProcessingException) { + throw IllegalStateException("Failed to serialize SatelliteDeletedEvent for satellite ${event.satelliteId}", exception) + } + + val record = ProducerRecord(topic, event.satelliteId.toString(), payload) + record.headers().add(TYPE_HEADER, PcpKafkaEvent.SatelliteDeletedEvent.name.toByteArray(StandardCharsets.UTF_8)) + template.send(record) + logger.info( + "Published SatelliteDeletedEvent: satelliteId={}, noradId={}, topic={}, eventType={}", + event.satelliteId, + event.noradId, + topic, + PcpKafkaEvent.SatelliteDeletedEvent + ) + } + + companion object { + private const val TYPE_HEADER = "type" + } +} diff --git a/services/pcp-satellite-catalog-service/src/main/resources/application.yaml b/services/pcp-satellite-catalog-service/src/main/resources/application.yaml new file mode 100644 index 0000000..370d5bd --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-satellite-catalog-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} diff --git a/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V1__initial.sql b/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V1__initial.sql new file mode 100644 index 0000000..53d40c9 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V1__initial.sql @@ -0,0 +1,300 @@ +CREATE TABLE IF NOT EXISTS satellite ( + id BIGINT PRIMARY KEY, + norad_id BIGINT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + type_code VARCHAR(64) NOT NULL, + active BOOLEAN NOT NULL, + scan_tle BOOLEAN NOT NULL, + red SMALLINT NOT NULL, + green SMALLINT NOT NULL, + blue SMALLINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS satellite_observation_profile ( + satellite_id BIGINT PRIMARY KEY REFERENCES satellite(id) ON DELETE CASCADE, + capture_angle DOUBLE PRECISION NOT NULL, + sun_angle_min DOUBLE PRECISION NOT NULL, + duration_min_seconds BIGINT NOT NULL, + duration_max_seconds BIGINT NOT NULL, + mmi_seconds BIGINT NOT NULL, + daily_max_duration_seconds BIGINT NOT NULL, + revolution_max_duration_seconds BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS satellite_slot_profile ( + satellite_id BIGINT PRIMARY KEY REFERENCES satellite(id) ON DELETE CASCADE, + cycle_revs BIGINT NOT NULL, + tn_calc TIMESTAMP NULL, + duration_calc_days BIGINT NOT NULL, + max_chain_length INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS satellite_slot_angle ( + satellite_id BIGINT NOT NULL REFERENCES satellite(id) ON DELETE CASCADE, + position INT NOT NULL, + angle_begin DOUBLE PRECISION NOT NULL, + angle_end DOUBLE PRECISION NOT NULL, + PRIMARY KEY (satellite_id, position) +); + +CREATE INDEX IF NOT EXISTS idx_satellite_norad_id ON satellite(norad_id); +CREATE INDEX IF NOT EXISTS idx_satellite_slot_angle_satellite_id ON satellite_slot_angle(satellite_id); + +INSERT INTO satellite (id, norad_id, code, name, type_code, active, scan_tle, red, green, blue) VALUES + (1, NULL, 'EMISSIO-01', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (2, NULL, 'EMISSIO-02', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (3, NULL, 'EMISSIO-03', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (4, NULL, 'EMISSIO-04', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (5, NULL, 'EMISSIO-05', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (6, NULL, 'EMISSIO-06', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (7, NULL, 'REFLEXIO-07', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78), + (8, NULL, 'REFLEXIO-08', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78), + (9, NULL, 'REFLEXIO-09', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78), + (10, NULL, 'REFLEXIO-10', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78), + (11, NULL, 'REFLEXIO-11', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 78), + (12, NULL, 'REFLEXIO-12', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86), + (13, NULL, 'REFLEXIO-13', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86), + (14, NULL, 'REFLEXIO-14', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86), + (15, NULL, 'REFLEXIO-15', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86), + (16, NULL, 'REFLEXIO-16', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 86), + (17, NULL, 'REFLEXIO-17', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94), + (18, NULL, 'REFLEXIO-18', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94), + (19, NULL, 'REFLEXIO-19', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94), + (20, NULL, 'REFLEXIO-20', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94), + (21, NULL, 'REFLEXIO-21', 'Reflexio', 'REFLEXIO', TRUE, FALSE, 125, 125, 94), + (22, NULL, 'EMISSIO-22', 'Emissio', 'EMISSIO', TRUE, FALSE, 0, 125, 0), + (666, NULL, 'REFLEXIO-EX-01', 'Reflexio_ex', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 255, 0, 0), + (667, NULL, 'REFLEXIO-EX-02', 'Reflexio_ex2', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 0, 255, 0), + (668, NULL, 'REFLEXIO-EX-03', 'Reflexio_ex3', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 0, 0, 255), + (669, NULL, 'REFLEXIO-EX-04', 'Reflexio_ex4', 'REFLEXIO_EXPERIMENTAL', TRUE, FALSE, 255, 255, 0), + (56756, 56756, 'KONDOR-FKA-01', 'KONDOR-FKA NO. 1', 'KONDOR_FKA', TRUE, TRUE, 255, 0, 0), + (62138, 62138, 'KONDOR-FKA-02', 'KONDOR-FKA NO. 2', 'KONDOR_FKA', TRUE, TRUE, 255, 125, 0); + +INSERT INTO satellite_observation_profile ( + satellite_id, + capture_angle, + sun_angle_min, + duration_min_seconds, + duration_max_seconds, + mmi_seconds, + daily_max_duration_seconds, + revolution_max_duration_seconds +) +SELECT + id, + 1.5, + -90.0, + 10, + 300, + 10, + 2100, + 300 +FROM satellite; + +INSERT INTO satellite_slot_profile (satellite_id, cycle_revs, tn_calc, duration_calc_days, max_chain_length) VALUES + (1, 275, '2026-03-20T07:20:00', 18, 3), + (2, 275, '2026-03-20T07:20:00', 18, 3), + (3, 275, '2026-03-20T07:20:00', 18, 3), + (4, 275, '2026-03-20T07:20:00', 18, 3), + (5, 275, '2026-03-20T07:20:00', 18, 3), + (6, 275, '2026-03-20T07:20:00', 18, 3), + (7, 275, '2026-03-20T03:00:00', 18, 3), + (8, 275, '2026-03-20T03:18:50.782779625', 18, 3), + (9, 275, '2026-03-20T03:37:40.764922495', 18, 3), + (10, 275, '2026-03-20T03:56:32.881376251', 18, 3), + (11, 275, '2026-03-20T04:15:27.865948215', 18, 3), + (12, 275, '2026-03-20T04:36:17.199469748', 18, 3), + (13, 275, '2026-03-20T04:36:17.199469748', 18, 3), + (14, 275, '2026-03-20T04:36:17.199469748', 18, 3), + (15, 275, '2026-03-20T04:36:17.199469748', 18, 3), + (16, 275, '2026-03-20T04:36:17.199469748', 18, 3), + (17, 275, '2026-03-20T06:12:34.090912147', 18, 3), + (18, 275, '2026-03-20T06:12:34.090912147', 18, 3), + (19, 275, '2026-03-20T06:12:34.090912147', 18, 3), + (20, 275, '2026-03-20T06:12:34.090912147', 18, 3), + (21, 275, '2026-03-20T06:12:34.090912147', 18, 3), + (22, 275, '2026-03-20T07:20:00', 18, 3), + (666, 500, '2028-06-01T00:00:00', 31, 3), + (667, 500, '2028-06-01T00:00:00', 31, 3), + (668, 500, '2028-06-01T00:00:00', 16, 3), + (669, 500, '2028-06-01T00:00:00', 16, 3), + (56756, 243, '2025-10-27T01:27:51.000031078', 16, 2), + (62138, 243, '2025-10-27T00:29:23.162630', 16, 3); + +INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end) +SELECT satellite_id, position, angle_begin, angle_end +FROM (VALUES + (56756), + (62138) +) AS satellites(satellite_id) +CROSS JOIN (VALUES + (1, 20.00, 23.00), + (2, 23.00, 26.00), + (3, 26.00, 29.00), + (4, 29.00, 32.00), + (5, 32.00, 35.00), + (6, 35.00, 38.00), + (7, 38.00, 41.00), + (8, 41.00, 44.00), + (9, 44.00, 47.00), + (10, 47.00, 50.00) +) AS angles(position, angle_begin, angle_end); + +INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end) +SELECT satellite_id, position, angle_begin, angle_end +FROM (VALUES + (1), + (2), + (3), + (4), + (5), + (6), + (22) +) AS satellites(satellite_id) +CROSS JOIN (VALUES + (1, 18.50, 21.58), + (2, 20.85, 23.82), + (3, 23.12, 25.98), + (4, 25.30, 28.05), + (5, 27.40, 30.03), + (6, 29.41, 31.92), + (7, 31.33, 33.73), + (8, 33.16, 35.45), + (9, 34.91, 37.08), + (10, 36.57, 38.63), + (11, 38.15, 40.11), + (12, 39.65, 41.52), + (13, 41.08, 42.85), + (14, 42.43, 44.11), + (15, 43.71, 45.30), + (16, 44.93, 46.44), + (17, 46.08, 47.51), + (18, 47.18, 48.54), + (19, 48.22, 49.51), + (20, 49.20, 50.42) +) AS angles(position, angle_begin, angle_end); + +INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end) +SELECT satellite_id, position, angle_begin, angle_end +FROM (VALUES + (7), + (8), + (9), + (10), + (11), + (12), + (13), + (14), + (15), + (16), + (17), + (18), + (19), + (20), + (21) +) AS satellites(satellite_id) +CROSS JOIN (VALUES + (1, 25.00, 23.70), + (2, 23.70, 22.40), + (3, 22.40, 21.10), + (4, 21.10, 19.80), + (5, 19.80, 18.50), + (6, 18.50, 17.20), + (7, 17.20, 15.90), + (8, 15.90, 14.60), + (9, 14.60, 13.30), + (10, 13.30, 12.00), + (11, 12.00, 10.70), + (12, 10.70, 9.40), + (13, 9.40, 8.10), + (14, 8.10, 6.80), + (15, 6.80, 5.50), + (16, 5.50, 4.20), + (17, 4.20, 2.90), + (18, 2.90, 1.60), + (19, 1.60, 0.30), + (20, 0.30, -1.00), + (21, -1.00, -2.30), + (22, -2.30, -3.60), + (23, -3.60, -4.90), + (24, -4.90, -6.20), + (25, -6.20, -7.50), + (26, -7.50, -8.80), + (27, -8.80, -10.10), + (28, -10.10, -11.40), + (29, -11.40, -12.70), + (30, -12.70, -14.00), + (31, -14.00, -15.30), + (32, -15.30, -16.60), + (33, -16.60, -17.90), + (34, -17.90, -19.20), + (35, -19.20, -20.50), + (36, -20.50, -21.80), + (37, -21.80, -23.10), + (38, -23.10, -24.40), + (39, -24.40, -25.70) +) AS angles(position, angle_begin, angle_end); + + + + + + +INSERT INTO satellite_slot_angle (satellite_id, position, angle_begin, angle_end) +SELECT satellite_id, position, angle_begin, angle_end +FROM (VALUES + (666), + (667), + (668), + (669) + ) AS satellites(satellite_id) + CROSS JOIN (VALUES + (40, 30.00, 28.70), + (41, 28.70, 27.40), + (42, 27.40, 26.10), + (43, 26.10, 24.80), + (44, 24.80, 23.50), + (45, 23.50, 22.20), + (46, 22.20, 20.90), + (47, 20.90, 19.60), + (48, 19.60, 18.30), + (49, 18.30, 17.00), + (50, 17.00, 15.70), + (51, 15.70, 14.40), + (52, 14.40, 13.10), + (53, 13.10, 11.80), + (54, 11.80, 10.50), + (55, 10.50, 9.20), + (56, 9.20, 7.90), + (57, 7.90, 6.60), + (58, 6.60, 5.30), + (59, 5.30, 4.00), + (60, 4.00, 2.70), + (61, 2.70, 1.40), + (62, 1.40, 0.10), + (63, 0.10, -1.20), + (64, -1.20, -2.50), + (65, -2.50, -3.80), + (66, -3.80, -5.10), + (67, -5.10, -6.40), + (68, -6.40, -7.70), + (69, -7.70, -9.00), + (70, -9.00, -10.30), + (71, -10.30, -11.60), + (72, -11.60, -12.90), + (73, -12.90, -14.20), + (74, -14.20, -15.50), + (75, -15.50, -16.80), + (76, -16.80, -18.10), + (77, -18.10, -19.40), + (78, -19.40, -20.70), + (79, -20.70, -22.00), + (80, -22.00, -23.30), + (81, -23.30, -24.60), + (82, -24.60, -25.90), + (83, -25.90, -27.20), + (84, -27.20, -28.50), + (85, -28.50, -29.80), + (86, -29.80, -31.10) +) AS angles(position, angle_begin, angle_end); \ No newline at end of file diff --git a/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V2__satellite_groups.sql b/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V2__satellite_groups.sql new file mode 100644 index 0000000..b8ae255 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V2__satellite_groups.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS satellite_group ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(128) NOT NULL +); + +CREATE TABLE IF NOT EXISTS satellite_group_satellite ( + group_id BIGINT NOT NULL REFERENCES satellite_group(id) ON DELETE CASCADE, + satellite_id BIGINT NOT NULL REFERENCES satellite(id) ON DELETE CASCADE, + PRIMARY KEY (group_id, satellite_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_satellite_group_name ON satellite_group(name); +CREATE INDEX IF NOT EXISTS idx_satellite_group_satellite_satellite_id + ON satellite_group_satellite(satellite_id); + +INSERT INTO satellite_group (name) +SELECT 'KONDOR' +WHERE NOT EXISTS ( + SELECT 1 + FROM satellite_group + WHERE name = 'KONDOR' +); + +INSERT INTO satellite_group_satellite (group_id, satellite_id) +SELECT satellite_group.id, satellites.satellite_id +FROM satellite_group +CROSS JOIN ( + VALUES + (56756), + (62138) +) AS satellites(satellite_id) +WHERE satellite_group.name = 'KONDOR' + AND NOT EXISTS ( + SELECT 1 + FROM satellite_group_satellite + WHERE satellite_group_satellite.group_id = satellite_group.id + AND satellite_group_satellite.satellite_id = satellites.satellite_id + ); diff --git a/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V3__slot_profile_slot_duration.sql b/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V3__slot_profile_slot_duration.sql new file mode 100644 index 0000000..71ac9ec --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/main/resources/db/migration/V3__slot_profile_slot_duration.sql @@ -0,0 +1,2 @@ +ALTER TABLE satellite_slot_profile + ADD COLUMN slot_duration BIGINT NOT NULL DEFAULT 10; diff --git a/services/pcp-satellite-catalog-service/src/test/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteCatalogControllerTest.kt b/services/pcp-satellite-catalog-service/src/test/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteCatalogControllerTest.kt new file mode 100644 index 0000000..c629cd8 --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/test/kotlin/space/nstart/pcp/pcp_satellite_catalog_service/controller/SatelliteCatalogControllerTest.kt @@ -0,0 +1,324 @@ +package space.nstart.pcp.pcp_satellite_catalog_service.controller + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatusCode +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +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.SatelliteGroupUpdateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SatelliteCatalogControllerTest { + + @LocalServerPort + private var port: Int = 0 + + @Test + fun `catalog returns seeded summaries`() { + val response = client() + .get() + .uri("/api/satellites") + .retrieve() + .bodyToFlux(SatelliteSummaryDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(28, response.size) + assertEquals("KONDOR-FKA NO. 1", response.first { it.id == 56756L }.name) + assertEquals(true, response.first { it.id == 62138L }.scanTle) + } + + @Test + fun `catalog returns full satellite card`() { + val response = client() + .get() + .uri("/api/satellites/22") + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .block()!! + + assertEquals(22L, response.id) + assertEquals("EMISSIO-22", response.code) + assertEquals("Emissio", response.name) + assertEquals(20, response.slotProfile?.defaultAngles?.size) + assertEquals(18.5, response.slotProfile?.defaultAngles?.first()?.angleBegin) + assertEquals(21.58, response.slotProfile?.defaultAngles?.first()?.angleEnd) + assertEquals(10L, response.slotProfile?.slotDuration) + assertEquals(300L, response.observationProfile?.durationMaxSeconds) + } + + @Test + fun `catalog returns profiles, batch and seeded groups`() { + val observationProfile = client() + .get() + .uri("/api/satellites/56756/observation-profile") + .retrieve() + .bodyToMono(SatelliteObservationProfileDTO::class.java) + .block()!! + val slotProfile = client() + .get() + .uri("/api/satellites/56756/slot-profile") + .retrieve() + .bodyToMono(SatelliteSlotProfileDTO::class.java) + .block()!! + val batch = client() + .post() + .uri("/api/satellites/batch") + .bodyValue(SatelliteBatchRequestDTO(ids = listOf(56756L, 62138L))) + .retrieve() + .bodyToFlux(SatelliteDTO::class.java) + .collectList() + .block() + .orEmpty() + val groups = client() + .get() + .uri("/api/satellite-groups") + .retrieve() + .bodyToFlux(SatelliteGroupDTO::class.java) + .collectList() + .block() + .orEmpty() + + assertEquals(1.5, observationProfile.captureAngle) + assertNotNull(slotProfile.tnCalc) + assertEquals(10L, slotProfile.slotDuration) + assertEquals(10, slotProfile.defaultAngles.size) + assertEquals(20.0, slotProfile.defaultAngles.first().angleBegin) + assertEquals(23.0, slotProfile.defaultAngles.first().angleEnd) + assertEquals(300L, observationProfile.durationMaxSeconds) + assertEquals(listOf(56756L, 62138L), batch.map { it.id }) + assertEquals(listOf(56756L, 62138L), groups.first { it.name == "KONDOR" }.satelliteIds) + } + + @Test + fun `catalog supports satellite crud`() { + val satelliteId = 70001L + val created = client() + .post() + .uri("/api/satellites") + .bodyValue( + SatelliteCreateDTO( + id = satelliteId, + noradId = 90001L, + code = "TEST-SAT-70001", + name = "TestSat", + typeCode = "TEST", + active = true, + scanTle = true, + visualization = SatelliteVisualizationDTO(red = 10, green = 20, blue = 30) + ) + ) + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .block()!! + + val updated = client() + .put() + .uri("/api/satellites/$satelliteId") + .bodyValue( + SatelliteUpdateDTO( + noradId = 90002L, + code = "TEST-SAT-70001-UPD", + name = "TestSatUpdated", + typeCode = "TEST-UPD", + active = false, + scanTle = false, + visualization = SatelliteVisualizationDTO(red = 40, green = 50, blue = 60) + ) + ) + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .block()!! + + delete("/api/satellites/$satelliteId") + + assertEquals(satelliteId, created.id) + assertEquals("TEST-SAT-70001", created.code) + assertEquals(90002L, updated.noradId) + assertEquals("TestSatUpdated", updated.name) + assertEquals(40, updated.visualization.red.toInt()) + assertEquals(400, status("GET", "/api/satellites/$satelliteId").value()) + } + + @Test + fun `catalog supports profile crud`() { + val satelliteId = 70002L + client() + .post() + .uri("/api/satellites") + .bodyValue( + SatelliteCreateDTO( + id = satelliteId, + code = "TEST-SAT-70002", + name = "ProfileSat", + typeCode = "TEST", + visualization = SatelliteVisualizationDTO(red = 1, green = 2, blue = 3) + ) + ) + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .block()!! + + val createdObservation = client() + .post() + .uri("/api/satellites/$satelliteId/observation-profile") + .bodyValue( + SatelliteObservationProfileDTO( + captureAngle = 2.5, + sunAngleMin = -15.0, + durationMinSeconds = 12, + durationMaxSeconds = 120, + mmiSeconds = 15, + dailyMaxDurationSeconds = 1800, + revolutionMaxDurationSeconds = 180 + ) + ) + .retrieve() + .bodyToMono(SatelliteObservationProfileDTO::class.java) + .block()!! + + val updatedObservation = client() + .put() + .uri("/api/satellites/$satelliteId/observation-profile") + .bodyValue(createdObservation.copy(durationMaxSeconds = 240)) + .retrieve() + .bodyToMono(SatelliteObservationProfileDTO::class.java) + .block()!! + + val createdSlot = client() + .post() + .uri("/api/satellites/$satelliteId/slot-profile") + .bodyValue( + SatelliteSlotProfileDTO( + cycleRevs = 10, + slotDuration = 15, + durationCalcDays = 5, + maxChainLength = 2, + defaultAngles = listOf( + SatelliteSlotAngleDTO(angleBegin = 10.0, angleEnd = 20.0), + SatelliteSlotAngleDTO(angleBegin = 20.0, angleEnd = 30.0) + ) + ) + ) + .retrieve() + .bodyToMono(SatelliteSlotProfileDTO::class.java) + .block()!! + + val updatedSlot = client() + .put() + .uri("/api/satellites/$satelliteId/slot-profile") + .bodyValue( + createdSlot.copy( + cycleRevs = 11, + slotDuration = 17, + defaultAngles = listOf(SatelliteSlotAngleDTO(angleBegin = 30.0, angleEnd = 40.0)) + ) + ) + .retrieve() + .bodyToMono(SatelliteSlotProfileDTO::class.java) + .block()!! + + delete("/api/satellites/$satelliteId/observation-profile") + delete("/api/satellites/$satelliteId/slot-profile") + + assertEquals(2.5, createdObservation.captureAngle) + assertEquals(240L, updatedObservation.durationMaxSeconds) + assertEquals(2, createdSlot.defaultAngles.size) + assertEquals(15L, createdSlot.slotDuration) + assertEquals(11L, updatedSlot.cycleRevs) + assertEquals(17L, updatedSlot.slotDuration) + assertEquals(30.0, updatedSlot.defaultAngles.single().angleBegin) + assertEquals(400, status("GET", "/api/satellites/$satelliteId/observation-profile").value()) + assertEquals(400, status("GET", "/api/satellites/$satelliteId/slot-profile").value()) + + delete("/api/satellites/$satelliteId") + } + + @Test + fun `catalog supports group crud`() { + val satelliteId = 70003L + client() + .post() + .uri("/api/satellites") + .bodyValue( + SatelliteCreateDTO( + id = satelliteId, + code = "TEST-SAT-70003", + name = "GroupSat", + typeCode = "TEST", + visualization = SatelliteVisualizationDTO(red = 7, green = 8, blue = 9) + ) + ) + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .block()!! + + val createdGroup = client() + .post() + .uri("/api/satellite-groups") + .bodyValue( + SatelliteGroupCreateDTO( + name = "TEST-GROUP", + satelliteIds = listOf(satelliteId, 56756L) + ) + ) + .retrieve() + .bodyToMono(SatelliteGroupDTO::class.java) + .block()!! + + val updatedGroup = client() + .put() + .uri("/api/satellite-groups/${createdGroup.id}") + .bodyValue( + SatelliteGroupUpdateDTO( + name = "TEST-GROUP-UPD", + satelliteIds = listOf(satelliteId) + ) + ) + .retrieve() + .bodyToMono(SatelliteGroupDTO::class.java) + .block()!! + + delete("/api/satellite-groups/${createdGroup.id}") + delete("/api/satellites/$satelliteId") + + assertEquals("TEST-GROUP", createdGroup.name) + assertEquals(listOf(56756L, satelliteId), createdGroup.satelliteIds) + assertEquals("TEST-GROUP-UPD", updatedGroup.name) + assertEquals(listOf(satelliteId), updatedGroup.satelliteIds) + assertEquals(400, status("GET", "/api/satellite-groups/${createdGroup.id}").value()) + } + + private fun delete(path: String) { + client() + .delete() + .uri(path) + .retrieve() + .toBodilessEntity() + .block() + } + + private fun status(method: String, path: String): HttpStatusCode = + client() + .method(org.springframework.http.HttpMethod.valueOf(method)) + .uri(path) + .exchangeToMono { response -> Mono.just(response.statusCode()) } + .block()!! + + private fun client(): WebClient = WebClient.create("http://localhost:$port") +} diff --git a/services/pcp-satellite-catalog-service/src/test/resources/application.yaml b/services/pcp-satellite-catalog-service/src/test/resources/application.yaml new file mode 100644 index 0000000..8bd743a --- /dev/null +++ b/services/pcp-satellite-catalog-service/src/test/resources/application.yaml @@ -0,0 +1,20 @@ +spring: + config: + import: "optional:configserver:" + cloud: + config: + enabled: false + datasource: + url: jdbc:h2:mem:pcp_satellite_catalog;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + flyway: + enabled: true + locations: classpath:db/migration diff --git a/services/pcp-srpring-boot-admin-server/Dockerfile b/services/pcp-srpring-boot-admin-server/Dockerfile new file mode 100644 index 0000000..e5ffd01 --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="-jar" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 38889 +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/services/pcp-srpring-boot-admin-server/README.md b/services/pcp-srpring-boot-admin-server/README.md new file mode 100644 index 0000000..b4be187 --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/README.md @@ -0,0 +1,13 @@ +# pcp-srpring-boot-admin-server + +Spring Boot Admin Server для PCP. + +Сервер показывает состояние и actuator-метрики приложений PCP, которые регистрируются через `spring-boot-admin-starter-client`. + +Переменные окружения: + +- `SERVER_PORT`: порт admin server, по умолчанию `38889` + +Запуск: + +`./gradlew :services:pcp-srpring-boot-admin-server:bootRun` diff --git a/services/pcp-srpring-boot-admin-server/build.gradle.kts b/services/pcp-srpring-boot-admin-server/build.gradle.kts new file mode 100644 index 0000000..a6df2bf --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/build.gradle.kts @@ -0,0 +1,75 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +version = "1.0.0" + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("de.codecentric:spring-boot-admin-starter-server:${property("versions.spring.boot.admin")}") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") + systemProperty("spring.config.name", "test-application") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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-srpring-boot-admin-server/src/main/kotlin/space/nstart/pcp/pcp_srpring_boot_admin_server/PcpSrpringBootAdminServerApplication.kt b/services/pcp-srpring-boot-admin-server/src/main/kotlin/space/nstart/pcp/pcp_srpring_boot_admin_server/PcpSrpringBootAdminServerApplication.kt new file mode 100644 index 0000000..4becaf0 --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/src/main/kotlin/space/nstart/pcp/pcp_srpring_boot_admin_server/PcpSrpringBootAdminServerApplication.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_srpring_boot_admin_server + +import de.codecentric.boot.admin.server.config.EnableAdminServer +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@EnableAdminServer +@SpringBootApplication +class PcpSrpringBootAdminServerApplication + + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml b/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml new file mode 100644 index 0000000..bcdb4e8 --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml @@ -0,0 +1,45 @@ +spring: + application: + name: pcp-srpring-boot-admin-server + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} + boot: + admin: + client: + enabled: false + +server: + port: ${SERVER_PORT:38889} + +management: + endpoints: + web: + exposure: + include: health,info,env,metrics + endpoint: + health: + show-details: always + +logging: + level: + de.codecentric.boot.admin: INFO + +--- +spring: + config: + activate: + on-profile: test + cloud: + config: + enabled: false + +server: + port: 0 diff --git a/services/pcp-srpring-boot-admin-server/src/test/kotlin/space/nstart/pcp/pcp_srpring_boot_admin_server/PcpSrpringBootAdminServerApplicationTests.kt b/services/pcp-srpring-boot-admin-server/src/test/kotlin/space/nstart/pcp/pcp_srpring_boot_admin_server/PcpSrpringBootAdminServerApplicationTests.kt new file mode 100644 index 0000000..2a64f29 --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/src/test/kotlin/space/nstart/pcp/pcp_srpring_boot_admin_server/PcpSrpringBootAdminServerApplicationTests.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_srpring_boot_admin_server + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class PcpSrpringBootAdminServerApplicationTests { + + @Test + fun contextLoads() { + } +} diff --git a/services/pcp-srpring-boot-admin-server/src/test/resources/test-application.yaml b/services/pcp-srpring-boot-admin-server/src/test/resources/test-application.yaml new file mode 100644 index 0000000..4f88104 --- /dev/null +++ b/services/pcp-srpring-boot-admin-server/src/test/resources/test-application.yaml @@ -0,0 +1,15 @@ +spring: + application: + name: pcp-srpring-boot-admin-server-test + cloud: + config: + enabled: false + import-check: + enabled: false + boot: + admin: + client: + enabled: false + +server: + port: 0 diff --git a/services/pcp-stations-service/Dockerfile b/services/pcp-stations-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-stations-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-stations-service/build.gradle.kts b/services/pcp-stations-service/build.gradle.kts new file mode 100644 index 0000000..695765b --- /dev/null +++ b/services/pcp-stations-service/build.gradle.kts @@ -0,0 +1,105 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +version = "1.0.0" + + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}") + implementation("org.springframework.cloud:spring-cloud-starter-config") + + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") +// implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + implementation("org.locationtech.jts:jts-core:1.19.0") + + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + testRuntimeOnly("com.h2database:h2") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + enabled = true + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/PcpStationsServiceApplication.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/PcpStationsServiceApplication.kt new file mode 100644 index 0000000..4184c89 --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/PcpStationsServiceApplication.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.pcp_stations_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class PcpStationsServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/GlobalExceptionHandler.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..456eb76 --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_stations_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(WebExchangeBindException::class) + fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { + val errors = mutableMapOf() + ex.bindingResult.fieldErrors.forEach { error -> + errors[error.field] = error.defaultMessage ?: "Validation error" + } + return Mono.just(ResponseEntity.badRequest().body(errors)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} \ No newline at end of file diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/ValidationConfig.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/ValidationConfig.kt new file mode 100644 index 0000000..5525837 --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/configuration/ValidationConfig.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.pcp_stations_service.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator { + return Validation.buildDefaultValidatorFactory().validator + } +} \ No newline at end of file diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationController.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationController.kt new file mode 100644 index 0000000..3712f33 --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationController.kt @@ -0,0 +1,34 @@ +package space.nstart.pcp.pcp_stations_service.controller + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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_stations_service.service.StationService +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import java.util.UUID + + +@RestController +@RequestMapping("api/stations") +class StationController { + + @Autowired + private lateinit var stationService: StationService + + @GetMapping() + fun all() = stationService.all() + + @GetMapping("/{station_id}") + fun byId(@PathVariable("station_id") id : UUID): StationDTO = stationService.byId(id) + + @PostMapping() + fun add(@RequestBody body : StationDTO): StationDTO = stationService.add(body) + + @DeleteMapping("{station_id}") + fun delete(@PathVariable("station_id") id : UUID): Int = stationService.delete(id) +} diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/entity/StationEntity.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/entity/StationEntity.kt new file mode 100644 index 0000000..47940f7 --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/entity/StationEntity.kt @@ -0,0 +1,53 @@ +package space.nstart.pcp.pcp_stations_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +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.PositionDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import java.util.UUID + +@Entity +@Table(name = "stations") +class StationEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val stationId : UUID? = null, + @Column(nullable = false) + val number : Int = 0, + @Column(nullable = false) + val name : String = "", + @Column(nullable = false) + val longitude : Double = 0.0, + @Column(nullable = false) + val latitude : Double = 0.0, + @Column(nullable = false) + val height : Double = 0.0, + @Column(name = "elevation_min", nullable = false) + val elevationMin : Double = 0.0, + @Column(name = "elevation_max", nullable = false) + val elevationMax : Double = 0.0, +){ + constructor(s : StationDTO) : this( + s.id, + s.number, + s.name, + s.position.long, + s.position.lat, + s.position.height, + s.elevationMin, + s.elevationMax + ) + + fun toDTO() = StationDTO( + stationId, + number, + name, + PositionDTO(latitude, longitude, height), + elevationMin, + elevationMax + ) +} diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/repository/StationRepository.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/repository/StationRepository.kt new file mode 100644 index 0000000..23e6ab9 --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/repository/StationRepository.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.pcp_stations_service.repository + + +import jakarta.transaction.Transactional +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 space.nstart.pcp.pcp_stations_service.entity.StationEntity +import java.util.Optional +import java.util.UUID + + +interface StationRepository : JpaRepository { + + fun findByStationId(id : UUID) : Optional + + @Modifying + @Transactional + @Query("DELETE FROM stations WHERE station_id =:id", nativeQuery = true) + fun deleteByStationId(@Param("id") id : UUID) : Int + +} diff --git a/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt new file mode 100644 index 0000000..13bb8cf --- /dev/null +++ b/services/pcp-stations-service/src/main/kotlin/space/nstart/pcp/pcp_stations_service/service/StationService.kt @@ -0,0 +1,30 @@ +package space.nstart.pcp.pcp_stations_service.service + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_stations_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_stations_service.entity.StationEntity +import space.nstart.pcp.pcp_stations_service.repository.StationRepository +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import java.util.UUID + +@Service +class StationService { + + + @Autowired + private lateinit var stationRepository: StationRepository + + + + fun all() = stationRepository.findAll().map { it.toDTO() } + + fun add(station : StationDTO): StationDTO = stationRepository.save(StationEntity(station)).toDTO() + + fun byId(id : UUID): StationDTO = + stationRepository.findByStationId(id) + .map { it.toDTO() } + .orElseThrow { CustomErrorException("Станция $id не найдена") } + + fun delete(id : UUID): Int = stationRepository.deleteByStationId(id) +} diff --git a/services/pcp-stations-service/src/main/resources/application.yaml b/services/pcp-stations-service/src/main/resources/application.yaml new file mode 100644 index 0000000..3609f1e --- /dev/null +++ b/services/pcp-stations-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-stations-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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} diff --git a/services/pcp-stations-service/src/main/resources/db/migration/V1__initial.sql b/services/pcp-stations-service/src/main/resources/db/migration/V1__initial.sql new file mode 100644 index 0000000..4433281 --- /dev/null +++ b/services/pcp-stations-service/src/main/resources/db/migration/V1__initial.sql @@ -0,0 +1,14 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + + +CREATE TABLE IF NOT EXISTS stations ( + station_id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + number INT NOT NULL, + name VARCHAR(55) NOT NULL, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + height DOUBLE PRECISION NOT NULL, + elevation_min DOUBLE PRECISION NOT NULL, + elevation_max DOUBLE PRECISION NOT NULL +); +CREATE INDEX idx_stations_number ON stations(number); \ No newline at end of file diff --git a/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/PcpStationsServiceApplicationTests.kt b/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/PcpStationsServiceApplicationTests.kt new file mode 100644 index 0000000..423a9c8 --- /dev/null +++ b/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/PcpStationsServiceApplicationTests.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.pcp_stations_service + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class PcpStationsServiceApplicationTests { + + @Test + fun contextLoads() { + } + +} diff --git a/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt b/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt new file mode 100644 index 0000000..caa52b5 --- /dev/null +++ b/services/pcp-stations-service/src/test/kotlin/space/nstart/pcp/pcp_stations_service/controller/StationControllerTest.kt @@ -0,0 +1,102 @@ +package space.nstart.pcp.pcp_stations_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_stations_service.service.StationService +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import java.util.UUID +import kotlin.test.assertEquals + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.boot.admin.client.enabled=false" + ] +) +class StationControllerTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var stationService: StationService + + @Test + fun `station save endpoint returns dto with position`() { + val station = sampleStation() + doReturn(station).`when`(stationService).add(anyStation()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/stations") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(station) + .retrieve() + .bodyToMono(StationDTO::class.java) + .block()!! + + assertEquals(station.id, actual.id) + assertEquals(station.position.lat, actual.position.lat) + assertEquals(station.position.long, actual.position.long) + verify(stationService).add(anyStation()) + } + + @Test + fun `station get by id endpoint returns dto without optional wrapper`() { + val station = sampleStation() + val stationId = station.id!! + doReturn(station).`when`(stationService).byId(stationId) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/stations/$stationId") + .retrieve() + .bodyToMono(StationDTO::class.java) + .block()!! + + assertEquals(stationId, actual.id) + assertEquals(station.name, actual.name) + verify(stationService).byId(stationId) + } + + @Test + fun `station delete endpoint returns deleted rows count`() { + val stationId = UUID.fromString("3ad079f2-625f-47bc-a2b8-4e0c5f7d5c16") + doReturn(1).`when`(stationService).delete(stationId) + + val actual = WebClient.create("http://localhost:$port") + .delete() + .uri("/api/stations/$stationId") + .retrieve() + .bodyToMono(Int::class.java) + .block()!! + + assertEquals(1, actual) + verify(stationService).delete(stationId) + } + + private fun sampleStation() = StationDTO( + id = UUID.fromString("2f1d1d8b-588d-4d4e-a456-80a5c93e09dd"), + number = 7, + name = "Station 7", + position = PositionDTO( + lat = 55.75, + long = 37.62, + height = 180.0 + ), + elevationMin = 5.0, + elevationMax = 88.0 + ) + + private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO() +} diff --git a/services/pcp-stations-service/src/test/resources/application.yaml b/services/pcp-stations-service/src/test/resources/application.yaml new file mode 100644 index 0000000..e050b58 --- /dev/null +++ b/services/pcp-stations-service/src/test/resources/application.yaml @@ -0,0 +1,19 @@ +spring: + config: + import: "optional:configserver:" + cloud: + config: + enabled: false + datasource: + url: jdbc:h2:mem:pcp_stations;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + flyway: + enabled: false diff --git a/services/pcp-tgu-service/Dockerfile b/services/pcp-tgu-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-tgu-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-tgu-service/README.md b/services/pcp-tgu-service/README.md new file mode 100644 index 0000000..57104ae --- /dev/null +++ b/services/pcp-tgu-service/README.md @@ -0,0 +1,78 @@ +# Spacecraft Planning Service + +Сервис планирования наблюдений космических аппаратов. + +Сервис получает данные о доступных пунктах наблюдения и окнах наблюдения, +строит непрерывную цепочку планов для каждого космического аппарата +и отправляет уведомления перед началом каждого плана. + +--- + +# Основные возможности + +Сервис выполняет следующие функции: + +- хранит планы космических аппаратов +- строит непрерывную цепочку планов +- принимает обновления пунктов наблюдения +- пересчитывает планы при обновлении данных +- отправляет Kafka уведомления перед началом плана +- предоставляет REST API для чтения планов + +--- + +# Основные сущности + +## Plan + +План космического аппарата. + +Содержит: + +- spacecraftId +- startTime +- endTime +- insertionPointId + +--- + +## SpacecraftPoints + +Набор входных данных по одному космическому аппарату. + +Содержит: + +- spacecraftId +- список пунктов наблюдения + +--- + +## InsertionPoint + +Пункт закладки. + +Содержит: + +- pointId +- список окон наблюдения + +--- + +## ObservationWindow + +Окно наблюдения космического аппарата. + +Содержит: + +- startTime +- endTime +- elevationAngle + +--- + +# Алгоритм построения планов + +Алгоритм MVP: + +1. Все `observationWindow.startTime` считаются кандидатами на старт плана. +2. Для текущего старта ищется следующий старт: diff --git a/services/pcp-tgu-service/build.gradle.kts b/services/pcp-tgu-service/build.gradle.kts new file mode 100644 index 0000000..15f6bff --- /dev/null +++ b/services/pcp-tgu-service/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +group = "space.nstart.pcp" +version = "0.0.1" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + + // Spring Boot + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.0") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + + implementation("org.springframework.boot:spring-boot-starter") + implementation("io.camunda:camunda-spring-boot-4-starter:8.8.10") + implementation("org.springframework.cloud:spring-cloud-starter-config") + + // Kafka + implementation("org.springframework.boot:spring-boot-starter-kafka") + + // Jackson + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + // Kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // Validation + implementation("org.springframework.boot:spring-boot-starter-validation") + runtimeOnly("org.postgresql:postgresql") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.kafka:spring-kafka-test") +} + +tasks.withType { + useJUnitPlatform() +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} diff --git a/services/pcp-tgu-service/description.txt b/services/pcp-tgu-service/description.txt new file mode 100644 index 0000000..6a96e7e --- /dev/null +++ b/services/pcp-tgu-service/description.txt @@ -0,0 +1,19 @@ +Сервис принимает данные по окнам видимости космических аппаратов, строит по ним цепочку планов, хранит актуальную выборку в памяти, ведет tracked-состояние планов в Postgres, отдает данные по REST и автоматически запускает Camunda-процесс перед стартом подходящего плана. + +Текущий поток работы: +1. На старте и затем по расписанию сервис подтягивает окна видимости из внешнего API через `ExternalPointsUpdateScheduler.kt` и `ExternalPointsClient.kt`. +2. Дополнительно сервис принимает обновления по Kafka через `SpacecraftPointsKafkaConsumer.kt`. +3. Каждое обновление преобразуется в доменную модель и приводит к пересчету цепочки планов через `SpacecraftPointsService.kt` и `PlanCalculationService.kt`. +4. Актуальные планы хранятся в `PlanRepository.kt`, входные точки по КА в `SpacecraftPointsRepository.kt`. +5. REST API отдает планы, tracked-статусы, список платформ и диагностическое чтение внешних окон через контроллеры в `controller/`. +6. `PlanProcessScheduler.kt` периодически проверяет ближайшие планы и запускает BPMN `createSatelliteMission` через `SatelliteMissionProcessService.kt`. +7. `TrackedPlanService.kt` хранит жизненный цикл плана в таблице `tracked_plan`, а `SpacecraftPlanningStateService.kt` читает состояние текущего базового плана КА из `spacecraft_planning_state`. + +Что важно учитывать: +- Внешний API окон видимости уже подключен, но сервис критически зависит от его доступности и от доступности классификатора платформ. +- Актуальная расчетная цепочка хранится только в памяти, поэтому после рестарта прогревается заново. +- Подтвержденные планы и служебное состояние жизненного цикла лежат в Postgres через Flyway migrations `V1`-`V3`. +- Алгоритм планирования остается упрощенным: он работает по кандидатам, собранным из окон наблюдения, и не выглядит как финальная production-логика оптимизации. +- В модуле есть тесты, но на момент аудита `./gradlew :services:pcp-tgu-service:test` падает: 5 из 13 тестов красные. + +Итог: это рабочий Spring Boot сервис планирования КА с двумя каналами ingest, REST API, tracked persistence и интеграцией с Camunda. Основные ограничения сейчас связаны с in-memory хранением расчетного хвоста, незавершенной логикой around confirmation/base-plan и нестабильным тестовым состоянием. diff --git a/services/pcp-tgu-service/https nsi classifier api.txt b/services/pcp-tgu-service/https nsi classifier api.txt new file mode 100644 index 0000000..3a4d5cc --- /dev/null +++ b/services/pcp-tgu-service/https nsi classifier api.txt @@ -0,0 +1 @@ +https://nsi-classifier-api.k8s.265.nstart.local/classifiers/platforms diff --git a/services/pcp-tgu-service/platforms.json b/services/pcp-tgu-service/platforms.json new file mode 100644 index 0000000..80efc4b --- /dev/null +++ b/services/pcp-tgu-service/platforms.json @@ -0,0 +1 @@ +{"uuid":"17fe3173-9f64-4afe-856d-febdbce7774c","apiName":"platforms","guiName":"Космические аппараты","status":"ACTIVE","hierarchy":false,"validationRule":null,"fields":[{"uuid":"18775460-42ae-4b90-a444-d6d1e2d516c9","apiName":"name","guiName":"Название","status":"TEST","type":"TEXT","fkClassifierApiName":null,"fkFieldApiName":null},{"uuid":"b3e7e51a-f143-4b55-90ea-c018db6f5d1f","apiName":"umn","guiName":"УМН","status":"ACTIVE","type":"TEXT","fkClassifierApiName":null,"fkFieldApiName":null},{"uuid":"d783790b-772b-4637-b731-538a03c7444a","apiName":"platform_type_name","guiName":"Тип КА - название","status":"ACTIVE","type":"FK","fkClassifierApiName":"platform_types","fkFieldApiName":"name"},{"uuid":"315d7a11-e693-4026-958d-99a10eddcb4d","apiName":"platform_type_code","guiName":"Тип КА - код","status":"ACTIVE","type":"FK","fkClassifierApiName":"platform_types","fkFieldApiName":"code"},{"uuid":"a92431ba-02e8-4540-9cea-7240a79910dc","apiName":"description","guiName":"Описание","status":"DRAFT","type":"TEXT","fkClassifierApiName":null,"fkFieldApiName":null},{"uuid":"c71aacf6-9705-48b3-875d-c3db797663ed","apiName":"norad_id","guiName":"NORAD ID","status":"DRAFT","type":"BIGINT","fkClassifierApiName":null,"fkFieldApiName":null}],"content":[{"uuid":"1461d773-5008-465a-99ee-3f90aa3d2ae4","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"AI2T","norad_id":12345,"umn":"1","name":"Аист-2Т №1","description":null,"platform_type_name":"Аист-2Т"}},{"uuid":"9eb34926-abe7-4004-a36d-4cbab75207fd","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"AI2T","umn":"3","name":"Аист-2Т №3","description":null,"platform_type_name":"Аист-2Т"}},{"uuid":"86a6c49c-b15f-46e8-b454-d84f14424764","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"805","name":"БКА1","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"9def8050-acd3-4bbf-a27f-2f55c4ef2c12","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"OR","umn":"199","name":"Обзор-Р2","description":null,"platform_type_name":"Обзор-Р"}},{"uuid":"8d9c9b5b-bd29-46bb-90c1-d442b47ac5e4","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RDK","umn":"40","name":"Ресурс-ДК1","description":null,"platform_type_name":"Ресурс-ДК"}},{"uuid":"c3ed7943-bfb6-4e59-9618-554479bae65b","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"45","name":"Ресурс-П5","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"f3601a85-dc8c-4340-adbb-4f1d64f41794","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RPM","umn":"141","name":"Ресурс-ПМ1","description":null,"platform_type_name":"Ресурс-ПМ"}},{"uuid":"3e45ab01-eddb-40a5-82b5-bcec0e74a319","parentItemUuid":null,"status":"ARCHIVE","initiation":null,"expiration":null,"data":{"platform_type_code":"CNDR","umn":"69","name":"Кондор-ФКА №2","description":null,"platform_type_name":"Кондор-ФКА"}},{"uuid":"fa359247-e06e-43c2-a0ab-28055983ca66","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"315","name":"Канопус-В4","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"703adc6b-50a2-44ff-84a3-259dbecfc764","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"42","name":"Ресурс-П2","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"d0eb88b4-62b0-49f1-89e6-534766169306","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"36","name":"Метеор-М2-3","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"5341d9df-a407-4438-9758-c4b062e839c3","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"33","name":"Метеор-М2","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"5a5cd4f9-8377-4659-ad74-a464c3fd41fa","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"32","name":"Метеор-М1","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"6088ec48-0688-472c-9655-69b2f91b1677","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"34","name":"Метеор-М2-1","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"7bd0db12-6e9b-45fa-9c1a-f8b266e21d58","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"313","name":"Канопус-В6","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"9d693053-9fa5-4f51-8c9c-e9b758d56f36","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"35","name":"Метеор-М2-2","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"2024817b-cad8-4148-a2f8-fd7f1ba32bac","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"314","name":"Канопус-В3","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"1211c6c3-5144-4d6e-aff6-bc7561f46713","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"44","name":"Ресурс-П4","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"65e4c723-d406-4ece-9e72-53440a5b402c","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"41","name":"Ресурс-П1","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"429d4031-5c00-4f65-a5a8-07855cb07070","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"OR","umn":"121","name":"Обзор-Р1","description":null,"platform_type_name":"Обзор-Р"}},{"uuid":"a9ae7b76-6f7b-4c6b-b96b-ae9eceb4441e","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"316","name":"Канопус-В-ИК","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"a4660c7c-b004-44b1-a202-bdedf6d93085","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"37","name":"Метеор-М2-4","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"ec3d9cef-606d-4d2e-8a85-4d81f54fc986","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"806","name":"Канопус-В1","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"ee8ec1c5-15a8-4ac5-97c4-6bc5a73cbde3","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"43","name":"Ресурс-П3","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"1216b167-c983-41a4-802b-94921913f162","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"CNDR","norad_id":56756,"umn":"68","name":"Кондор-ФКА №1","description":"Тестовый аппарат для стенда сквозного прогона","platform_type_name":"Кондор-ФКА"}},{"uuid":"fb104d60-9a6f-4347-8805-cde63952fc48","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"312","name":"Канопус-В5","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"707767c8-b68a-4ffd-baca-a51fe631d326","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"CNDR","norad_id":62138,"umn":"69","name":"Кондор-ФКА №2","description":"Тестовый аппарат для стенда сквозного прогона","platform_type_name":"Кондор-ФКА"}}],"page":{"size":1000,"number":0,"totalElements":27,"totalPages":1}} \ No newline at end of file diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt new file mode 100644 index 0000000..6db9ea5 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp_tgu_service + +/** Запускает TGU-сервис и разворачивает его BPMN-описание при старте. */ +import io.camunda.client.annotation.Deployment +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication + + +@SpringBootApplication +@Deployment(resources = ["classpath:BPMN/createSatelliteMission.bpmn"]) +@ConfigurationPropertiesScan +class TguApplication + +/** Точка входа Spring Boot приложения. */ +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt new file mode 100644 index 0000000..53bd21e --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp_tgu_service.config + +/** Настраивает Jackson-сериализацию для payload'ов клиента Camunda. */ +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import io.camunda.client.api.JsonMapper as CamundaJsonMapper +import io.camunda.client.impl.CamundaObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class CamundaJacksonConfig { + + /** Создаёт JSON mapper для Camunda с поддержкой Kotlin-классов и Java time типов. */ + @Bean("camundaJsonMapper") + fun camundaJsonMapper(): CamundaJsonMapper { + val objectMapper = JsonMapper.builder() + .addModule(KotlinModule.Builder().build()) + .addModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build() + + return CamundaObjectMapper(objectMapper) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt new file mode 100644 index 0000000..4a54e2b --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Настройки внешних классификаторов. + */ +@ConfigurationProperties(prefix = "classifier") +data class ClassifierProperties( + + /** URL классификатора космических аппаратов. */ + val platformsUrl: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ExternalApiProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ExternalApiProperties.kt new file mode 100644 index 0000000..92377a0 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ExternalApiProperties.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Настройки внешнего API с окнами видимости КА. + */ +@ConfigurationProperties(prefix = "external-api") +data class ExternalApiProperties( + + /** Базовый URL сервиса расчёта видимости КА. */ + val baseUrl: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt new file mode 100644 index 0000000..dcd9a3f --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -0,0 +1,34 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Настройки планирования и автозапуска процессов по планам. + */ +@ConfigurationProperties(prefix = "planning") +data class PlanningProperties( + + /** За сколько минут до начала плана автоматически запускать Camunda-процесс. */ + val notificationBeforeStartMinutes: Long, + + /** Минимальный разрыв в минутах между стартами соседних планов при расчете цепочки. */ + val targetPlanDurationMinutes: Long, + + /** Допустимое отклонение от целевой длительности плана в минутах. */ + val targetPlanDurationDeltaMinutes: Long, + + /** Включает периодическое обновление пунктов из внешнего API. */ + val externalUpdateEnabled: Boolean, + + /** Горизонт запроса данных по пунктам из внешнего API в днях, начиная от текущего момента. */ + val externalPointsLookaheadDays: Long, + + /** Сколько дней подтвержденной истории планов держать в in-memory выдаче. */ + val inMemoryHistoryDays: Long, + + /** Интервал в миллисекундах между запросами во внешнее API. */ + val externalUpdateFixedDelayMs: Long, + + /** Интервал в миллисекундах между проверками, пора ли автозапускать Camunda-процесс по плану. */ + val notificationCheckFixedDelayMs: Long +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/SchedulingConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/SchedulingConfig.kt new file mode 100644 index 0000000..b99af97 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/SchedulingConfig.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +/** + * Конфигурация планировщика задач. + * + * Включает поддержку аннотации @Scheduled. + * Благодаря этому в сервисах можно создавать + * периодические задачи. + */ +@Configuration +@EnableScheduling +class SchedulingConfig diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt new file mode 100644 index 0000000..f272c06 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp_tgu_service.config + +/** Привязывает имена Kafka-топиков, используемых сервисом. */ +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "topics") +data class TopicsProperties( + val spacecraftPointsUpdate: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/ExternalPointsController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/ExternalPointsController.kt new file mode 100644 index 0000000..a7a06cd --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/ExternalPointsController.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp_tgu_service.controller + +/** Содержит диагностические методы API для чтения окон видимости из внешнего API. */ +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.format.annotation.DateTimeFormat +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 reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.LocalDateTime + +/** + * REST-контроллер для ручной проверки данных внешнего API по КА. + */ +@RestController +@RequestMapping("/api/external-points") +@Tag(name = "External Points", description = "Проверка данных окон видимости из внешнего API") +class ExternalPointsController( + private val externalPointsClient: ExternalPointsClient +) { + + /** + * Возвращает окна видимости по одному КА за указанный интервал. + */ + /** Загружает окна видимости для одного КА в указанном интервале. */ + @GetMapping("/{satelliteId}") + @Operation(summary = "Получить окна видимости по одному космическому аппарату") + fun getSpacecraftPoints( + @PathVariable satelliteId: Long, + @RequestParam("time_start") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + timeStart: LocalDateTime, + @RequestParam("time_stop") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + timeStop: LocalDateTime + ): Mono = + Mono.fromCallable { + externalPointsClient.fetchSpacecraftPoints(satelliteId, timeStart, timeStop) + }.subscribeOn(Schedulers.boundedElastic()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanConfirmationController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanConfirmationController.kt new file mode 100644 index 0000000..88bfc8c --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanConfirmationController.kt @@ -0,0 +1,44 @@ +package space.nstart.pcp_tgu_service.controller + +/** Содержит методы API для просмотра и фиксации подтверждения плана. */ +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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 reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import space.nstart.pcp_tgu_service.dto.PlanConfirmationRequest +import space.nstart.pcp_tgu_service.dto.TrackedPlanResponse +import space.nstart.pcp_tgu_service.service.PlanConfirmationService +import space.nstart.pcp_tgu_service.service.TrackedPlanService +import java.util.UUID + +@RestController +@RequestMapping("/api/plan-confirmations") +@Tag(name = "Plan Confirmations", description = "Подтверждение и просмотр статуса planId") +class PlanConfirmationController( + private val planConfirmationService: PlanConfirmationService, + private val trackedPlanService: TrackedPlanService +) { + + /** Возвращает состояние отслеживаемого плана по его идентификатору. */ + @GetMapping("/{planId}") + @Operation(summary = "Получить статус плана по planId") + fun getPlan(@PathVariable planId: UUID): Mono = + Mono.fromCallable { + TrackedPlanResponse.from(trackedPlanService.getByPlanId(planId)) + }.subscribeOn(Schedulers.boundedElastic()) + + /** Подтверждает план и только фиксирует этот факт в БД. */ + @PostMapping + @Operation(summary = "Подтвердить план по planId и сохранить подтверждение в БД") + fun confirmPlan(@Valid @RequestBody request: PlanConfirmationRequest): Mono = + Mono.fromCallable { + TrackedPlanResponse.from(planConfirmationService.confirm(request.planId)) + }.subscribeOn(Schedulers.boundedElastic()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt new file mode 100644 index 0000000..c0ab9f8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp_tgu_service.controller + +/** Содержит методы API для чтения текущих планов. */ +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 reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import space.nstart.pcp_tgu_service.dto.PlanResponse +import space.nstart.pcp_tgu_service.service.PlanQueryService + +@RestController +@RequestMapping("/api/plans") +class PlanController( + private val planQueryService: PlanQueryService +) { + + /** Возвращает планы по всем КА с указанным окном подтверждённой истории. */ + @GetMapping + fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono> = + Mono.fromCallable { + planQueryService.getAllPlans(historyDays).map { planView -> + PlanResponse.from(planView.plan, planView.status) + } + }.subscribeOn(Schedulers.boundedElastic()) + + /** Возвращает цепочку планов для одного КА. */ + @GetMapping("/{spacecraftId}") + fun getPlansBySpacecraftId( + @PathVariable spacecraftId: String, + @RequestParam(defaultValue = "7") historyDays: Long + ): Mono> = + Mono.fromCallable { + planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map { planView -> + PlanResponse.from(planView.plan, planView.status) + } + }.subscribeOn(Schedulers.boundedElastic()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt new file mode 100644 index 0000000..fb86c2f --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp_tgu_service.controller + +/** Содержит методы API только на чтение для платформ из внешнего классификатора. */ +import space.nstart.pcp_tgu_service.dto.ContentItemDTO +import space.nstart.pcp_tgu_service.service.PlatformService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +/** + * REST-контроллер для просмотра списка космических аппаратов. + */ +@RestController +@RequestMapping("/api/platforms") +@Tag(name = "Platforms", description = "Работа со списком космических аппаратов") +class PlatformController( + private val platformService: PlatformService +) { + + /** + * Возвращает список космических аппаратов из внешнего классификатора. + */ + /** Возвращает платформы с необязательной фильтрацией по типу и статусу. */ + @GetMapping + @Operation(summary = "Получить список космических аппаратов") + fun getPlatforms( + @RequestParam(required = false) platformTypeCode: String?, + @RequestParam(required = false) status: String? + ): Mono> = + Mono.fromCallable { + platformService.loadPlatforms(platformTypeCode, status) + }.subscribeOn(Schedulers.boundedElastic()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/SatelliteMissionController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/SatelliteMissionController.kt new file mode 100644 index 0000000..a41cf47 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/SatelliteMissionController.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp_tgu_service.controller + +/** Содержит ручные endpoint'ы для запуска BPMN-процесса спутниковой миссии. */ +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.service.SatelliteMissionProcessService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +/** + * REST-контроллер для ручного запуска BPMN-процесса спутниковой миссии. + */ +@RestController +@RequestMapping("/api/satellite-missions") +@Tag(name = "Satellite Missions", description = "Ручной запуск BPMN-процесса спутниковой миссии") +class SatelliteMissionController( + private val satelliteMissionProcessService: SatelliteMissionProcessService +) { + + /** + * Запускает процесс createSatelliteMission для аппарата по noradId. + */ + /** Запускает процесс миссии для аппарата, заданного через `noradId`. */ + @PostMapping("/{noradId}") + @Operation(summary = "Запустить процесс createSatelliteMission") + fun startMission(@PathVariable noradId: Long): Mono = + Mono.fromCallable { + satelliteMissionProcessService.startCreateSatelliteMissionManually(noradId) + }.subscribeOn(Schedulers.boundedElastic()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/InsertionPoint.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/InsertionPoint.kt new file mode 100644 index 0000000..4f5a099 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/InsertionPoint.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp_tgu_service.domain + +/** + * Пункт закладки (точка наблюдения). + * + * Один пункт может иметь несколько окон наблюдения, + * когда космический аппарат проходит над этим пунктом. + * + * @property pointId идентификатор пункта закладки. + * @property observationWindows список окон наблюдения для этого пункта. + */ +data class InsertionPoint( + + /** Идентификатор пункта закладки */ + val pointId: String, + + /** Окна наблюдения для данного пункта */ + val observationWindows: List +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/ObservationWindow.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/ObservationWindow.kt new file mode 100644 index 0000000..978018d --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/ObservationWindow.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp_tgu_service.domain + +import java.time.LocalDateTime + +/** + * Окно наблюдения космического аппарата. + * + * Это период времени, когда аппарат можно наблюдать + * из конкретного пункта закладки. + * + * @property startTime время начала окна наблюдения. + * @property endTime время окончания окна наблюдения. + * @property elevationAngle угол места (параметр наблюдения). + */ +data class ObservationWindow( + + /** Время начала окна наблюдения */ + val startTime: LocalDateTime, + + /** Время окончания окна наблюдения */ + val endTime: LocalDateTime, + + /** Угол места */ + val elevationAngle: Double +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/Plan.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/Plan.kt new file mode 100644 index 0000000..5860b7e --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/Plan.kt @@ -0,0 +1,28 @@ +package space.nstart.pcp_tgu_service.domain + +import java.time.LocalDateTime +import java.util.UUID + +/** + * План космического аппарата. + */ +data class Plan( + + /** Идентификатор космического аппарата */ + val spacecraftId: String, + + /** Время начала плана */ + val startTime: LocalDateTime, + + /** Время окончания плана */ + val endTime: LocalDateTime, + + /** Идентификатор стартового пункта закладки */ + val kppId: String, + + /** UUID-идентификатор плана */ + val planId: UUID = UUID.randomUUID(), + + /** Было ли уже отправлено уведомление о предстоящем старте */ + val notified: Boolean = false +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/SpacecraftPoints.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/SpacecraftPoints.kt new file mode 100644 index 0000000..b043365 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/SpacecraftPoints.kt @@ -0,0 +1,19 @@ +package space.nstart.pcp_tgu_service.domain + +/** + * Набор доступных пунктов наблюдения для одного космического аппарата. + * + * Этот объект представляет все входные данные, + * которые сервис получает по конкретному КА. + * + * @property spacecraftId идентификатор космического аппарата. + * @property points список пунктов закладки. + */ +data class SpacecraftPoints( + + /** Идентификатор космического аппарата */ + val spacecraftId: String, + + /** Список доступных пунктов закладки */ + val points: List +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt new file mode 100644 index 0000000..b54b184 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp_tgu_service.dto + +/** Набор переменных, передаваемых в Camunda при запуске процесса спутниковой миссии. */ +import java.time.LocalDateTime + +data class CreateSatelliteMissionVariables( + val spacecraftId: String, + val satellitePlanId: String, + val planStartTime: LocalDateTime, + val planEndTime: LocalDateTime, + val kppId: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ExternalRvaDto.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ExternalRvaDto.kt new file mode 100644 index 0000000..2c73f30 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ExternalRvaDto.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp_tgu_service.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +/** + * DTO одного окна видимости КА из внешнего API. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ExternalRvaItemDto( + val noradId: Long, + val stationId: Long, + val revolution: Long?, + val onStart: ExternalRvaPointDto, + val onMaximum: ExternalRvaPointDto, + val onStop: ExternalRvaPointDto +) + +/** + * DTO временной точки в ответе внешнего API. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ExternalRvaPointDto( + val time: String, + val elevation: Double, + val azimuth: Double?, + val range: Double? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanConfirmationRequest.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanConfirmationRequest.kt new file mode 100644 index 0000000..ec8bbf4 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanConfirmationRequest.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp_tgu_service.dto + +/** REST DTO запроса для подтверждения отслеживаемого плана по идентификатору. */ +import jakarta.validation.constraints.NotNull +import java.util.UUID + +data class PlanConfirmationRequest( + @field:NotNull + val planId: UUID +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt new file mode 100644 index 0000000..127126e --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -0,0 +1,47 @@ +package space.nstart.pcp_tgu_service.dto + +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import java.time.LocalDateTime +import java.util.UUID + +/** + * DTO ответа REST API для плана. + */ +data class PlanResponse( + + /** UUID-идентификатор плана */ + val planId: UUID, + + /** Идентификатор космического аппарата */ + val spacecraftId: String, + + /** Время начала плана */ + val startTime: LocalDateTime, + + /** Время окончания плана */ + val endTime: LocalDateTime, + + /** Идентификатор пункта закладки */ + val kppId: String, + + /** Статус плана в жизненном цикле выдачи. */ + val status: TrackedPlanStatus +) { + + companion object { + + /** + * Преобразует доменную сущность плана в DTO ответа. + */ + fun from(plan: Plan, status: TrackedPlanStatus): PlanResponse = + PlanResponse( + planId = plan.planId, + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + status = status + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt new file mode 100644 index 0000000..201818b --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt @@ -0,0 +1,250 @@ +package space.nstart.pcp_tgu_service.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.ObjectMapper + +/** + * Корневой DTO JSON-ответа. + * Соответствует всему объекту JSON. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class PlatformsResponseDTO( + + /** + * UUID справочника. + */ + val uuid: String?, + + /** + * API имя сущности. + */ + val apiName: String?, + + /** + * GUI имя сущности. + */ + val guiName: String?, + + /** + * Статус справочника. + */ + val status: String?, + + /** + * Признак иерархии. + */ + val hierarchy: Boolean?, + + /** + * Правило валидации. + */ + val validationRule: String?, + + /** + * Список описаний полей справочника. + */ + val fields: List?, + + /** + * Основной список элементов. + */ + val content: List?, + + /** + * Информация о пагинации. + */ + val page: PageDTO? +) + +/** + * DTO описания одного поля. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class FieldDTO( + + /** + * UUID поля. + */ + val uuid: String?, + + /** + * API имя поля. + */ + val apiName: String?, + + /** + * GUI имя поля. + */ + val guiName: String?, + + /** + * Статус поля. + */ + val status: String?, + + /** + * Тип поля. + */ + val type: String?, + + /** + * API имя связанного классификатора. + */ + val fkClassifierApiName: String?, + + /** + * API имя связанного поля. + */ + val fkFieldApiName: String? +) + +/** + * DTO одного элемента массива content. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentItemDTO( + + /** + * UUID записи. + */ + val uuid: String?, + + /** + * UUID родительского элемента. + */ + val parentItemUuid: String?, + + /** + * Статус записи. + */ + val status: String?, + + /** + * Дата начала действия. + */ + val initiation: String?, + + /** + * Дата окончания действия. + */ + val expiration: String?, + + /** + * Вложенный объект данных. + */ + val data: ContentDataDTO? +) + +/** + * DTO вложенного объекта data внутри content. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ContentDataDTO( + + /** + * Код типа платформы. + */ + @param:JsonProperty("platform_type_code") + val platformTypeCode: String?, + + /** + * NORAD ID. + */ + @param:JsonProperty("norad_id") + val noradId: Long?, + + /** + * Уникальный номер изделия (УМН). + */ + val umn: String?, + + /** + * Название аппарата. + */ + val name: String?, + + /** + * Описание аппарата. + */ + val description: String?, + + /** + * Название типа платформы. + */ + @param:JsonProperty("platform_type_name") + val platformTypeName: String? +) + +/** + * DTO блока page (пагинация). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class PageDTO( + + /** + * Размер страницы. + */ + val size: Int?, + + /** + * Номер страницы. + */ + val number: Int?, + + /** + * Общее количество элементов. + */ + val totalElements: Long?, + + /** + * Общее количество страниц. + */ + val totalPages: Int? +) + +/** + * Класс парсера JSON. + */ +class PlatformsJsonParser( + + /** + * ObjectMapper Jackson. + * Предоставляется Spring Boot автоматически. + */ + private val objectMapper: ObjectMapper +) { + + /** + * Парсит весь JSON в корневой DTO. + * + * @param json JSON строка + * @return PlatformsResponseDTO + */ + fun parse(json: String): PlatformsResponseDTO { + + /** + * Десериализация JSON -> Kotlin объект. + */ + return objectMapper.readValue(json, PlatformsResponseDTO::class.java) + } + + /** + * Парсит JSON и возвращает только массив content. + * + * @param json JSON строка + * @return список элементов content + */ + fun parseContent(json: String): List { + + /** + * Парсим корневой объект. + */ + val response = objectMapper.readValue(json, PlatformsResponseDTO::class.java) + + /** + * Если content отсутствует — возвращаем пустой список. + */ + return response.content.orEmpty() + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt new file mode 100644 index 0000000..962b870 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp_tgu_service.dto + +/** REST DTO, возвращаемый после запуска нового экземпляра процесса Camunda. */ +import java.util.UUID + +data class ProcessStartResponse( + val processInstanceKey: Long, + val processDefinitionKey: Long, + val bpmnProcessId: String, + val version: Int, + val planId: UUID +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/SpacecraftPointsMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/SpacecraftPointsMessage.kt new file mode 100644 index 0000000..d9612fb --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/SpacecraftPointsMessage.kt @@ -0,0 +1,53 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime + +/** + * DTO сообщения из Kafka с обновлением данных + * по одному космическому аппарату. + * + * @property spacecraftId идентификатор космического аппарата. + * @property points список пунктов наблюдения. + */ +data class SpacecraftPointsMessage( + + /** Идентификатор космического аппарата */ + val spacecraftId: String, + + /** Список пунктов наблюдения */ + val points: List +) + +/** + * DTO пункта закладки. + * + * @property pointId идентификатор пункта. + * @property observationWindows окна наблюдения. + */ +data class PointMessage( + + /** Идентификатор пункта */ + val pointId: String, + + /** Окна наблюдения */ + val observationWindows: List +) + +/** + * DTO окна наблюдения. + * + * @property startTime время начала окна. + * @property endTime время окончания окна. + * @property elevationAngle угол места. + */ +data class ObservationWindowMessage( + + /** Время начала окна наблюдения */ + val startTime: LocalDateTime, + + /** Время окончания окна наблюдения */ + val endTime: LocalDateTime, + + /** Угол места */ + val elevationAngle: Double +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/TrackedPlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/TrackedPlanResponse.kt new file mode 100644 index 0000000..d22b131 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/TrackedPlanResponse.kt @@ -0,0 +1,33 @@ +package space.nstart.pcp_tgu_service.dto + +/** REST DTO с сохранённым состоянием исполнения отслеживаемого плана. */ +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import java.time.LocalDateTime +import java.util.UUID + +data class TrackedPlanResponse( + val planId: UUID, + val spacecraftId: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val kppId: String, + val processInstanceKey: Long?, + val status: TrackedPlanStatus, + val confirmedAt: LocalDateTime? +) { + companion object { + /** Преобразует сущность отслеживаемого плана в DTO ответа API. */ + fun from(entity: TrackedPlanEntity): TrackedPlanResponse = + TrackedPlanResponse( + planId = entity.planId, + spacecraftId = entity.spacecraftId, + startTime = entity.startTime, + endTime = entity.endTime, + kppId = entity.kppId, + processInstanceKey = entity.processInstanceKey, + status = entity.status, + confirmedAt = entity.confirmedAt + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt new file mode 100644 index 0000000..0f0e602 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp_tgu_service.entity + +/** JPA-сущность, хранящая текущий подтверждённый базовый план для КА. */ +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "spacecraft_planning_state") +class SpacecraftPlanningStateEntity( + @Id + @Column(name = "spacecraft_id", nullable = false, updatable = false) + var spacecraftId: String, + + @Column(name = "current_base_plan_id") + var currentBasePlanId: UUID? = null, + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(spacecraftId = "") +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt new file mode 100644 index 0000000..b9615e9 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt @@ -0,0 +1,63 @@ +package space.nstart.pcp_tgu_service.entity + +/** JPA-сущность, хранящая план вместе с его процессным и подтверждающим жизненным циклом. */ +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Entity +@Table(name = "tracked_plan") +class TrackedPlanEntity( + @Id + @Column(name = "plan_id", nullable = false, updatable = false) + var planId: UUID, + + @Column(name = "spacecraft_id", nullable = false) + var spacecraftId: String, + + @Column(name = "start_time", nullable = false) + var startTime: LocalDateTime, + + @Column(name = "end_time", nullable = false) + var endTime: LocalDateTime, + + @Column(name = "kpp_id", nullable = false) + var kppId: String, + + @Column(name = "process_instance_key") + var processInstanceKey: Long? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "confirmed_at") + var confirmedAt: LocalDateTime? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: TrackedPlanStatus = TrackedPlanStatus.WAITING_CONFIRMATION, + + @Enumerated(EnumType.STRING) + @Column(name = "plan_source", nullable = false) + var planSource: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN, + + @Column(name = "base_plan_id") + var basePlanId: UUID? = null, + + @Column(name = "superseded_by_plan_id") + var supersededByPlanId: UUID? = null +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this( + planId = UUID.randomUUID(), + spacecraftId = "", + startTime = LocalDateTime.MIN, + endTime = LocalDateTime.MIN, + kppId = "" + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt new file mode 100644 index 0000000..beab660 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp_tgu_service.entity + +/** Описывает способ создания отслеживаемого плана. */ +enum class TrackedPlanSource { + AUTO_CHAIN, + MANUAL +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt new file mode 100644 index 0000000..57d2131 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp_tgu_service.entity + +/** Описывает состояние жизненного цикла отслеживаемого плана. */ +enum class TrackedPlanStatus { + NEW, + WAITING_CONFIRMATION, + CONFIRMED, + SUPERSEDED +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt new file mode 100644 index 0000000..c16cf93 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt @@ -0,0 +1,171 @@ +package space.nstart.pcp_tgu_service.integration.api + +/** Вызывает внешнее API видимости и преобразует его ответ в доменные модели. */ +import space.nstart.pcp_tgu_service.config.ExternalApiProperties +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.InsertionPoint +import space.nstart.pcp_tgu_service.domain.ObservationWindow +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.dto.ContentItemDTO +import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto +import space.nstart.pcp_tgu_service.service.PlatformService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientException +import org.springframework.web.reactive.function.client.WebClientResponseException +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit + +/** + * Клиент внешнего API с окнами видимости КА. + */ +@Component +class ExternalPointsClient( + externalApiProperties: ExternalApiProperties, + private val platformService: PlatformService, + private val planningProperties: PlanningProperties +) { + + private val log = LoggerFactory.getLogger(javaClass) + private val zoneId: ZoneId = ZoneId.systemDefault() + + private val webClient: WebClient = WebClient.builder() + .baseUrl(externalApiProperties.baseUrl) + .build() + + /** + * Загружает окна видимости по всем КА из классификатора, + * у которых заполнен NORAD ID. + */ + /** Загружает окна видимости для всех КА с заполненным NORAD-идентификатором в классификаторе. */ + fun fetchAllSpacecraftPoints(): List { + val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) + + return platformService.loadPlatforms() + .mapNotNull { platform -> + val noradId = platform.data?.noradId ?: return@mapNotNull null + fetchSpacecraftPointsSafely( + platform = platform, + satelliteId = noradId, + timeStart = timeStart, + timeStop = timeStop + ) + } + } + + /** Оборачивает загрузку по одному КА и подавляет транспортные ошибки только для него. */ + private fun fetchSpacecraftPointsSafely( + platform: ContentItemDTO, + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): SpacecraftPoints? = + try { + fetchSpacecraftPoints( + satelliteId = satelliteId, + timeStart = timeStart, + timeStop = timeStop + ) + } catch (ex: WebClientException) { + logExternalApiError(platform, satelliteId, timeStart, timeStop, ex) + null + } + + /** + * Загружает окна видимости по одному КА и интервалу времени. + */ + /** Загружает окна видимости для одного КА в указанном временном интервале. */ + fun fetchSpacecraftPoints( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): SpacecraftPoints { + val response = webClient.get() + .uri { uriBuilder -> + uriBuilder + .path("/api/satellites/{satelliteId}/rva") + .queryParam("time_start", timeStart) + .queryParam("time_stop", timeStop) + .build(satelliteId) + } + .retrieve() + .bodyToMono(Array::class.java) + .block() + ?.toList() + .orEmpty() + + return mapToDomain(satelliteId, response) + } + + /** Группирует внешние записи RVA по станциям и переводит их во внутреннюю модель планирования. */ + private fun mapToDomain( + satelliteId: Long, + response: List + ): SpacecraftPoints { + val points = response + .groupBy { it.stationId } + .map { (stationId, items) -> + InsertionPoint( + pointId = stationId.toString(), + observationWindows = items.map { item -> + ObservationWindow( + startTime = toLocalDateTime(item.onStart.time), + endTime = toLocalDateTime(item.onStop.time), + elevationAngle = item.onMaximum.elevation + ) + }.sortedBy { it.startTime } + ) + } + .sortedBy { it.pointId } + + return SpacecraftPoints( + spacecraftId = satelliteId.toString(), + points = points + ) + } + + /** Разбирает строку времени из внешнего API в локальную для сервиса модель даты и времени. */ + private fun toLocalDateTime(value: String): LocalDateTime = + try { + Instant.parse(value).atZone(zoneId).toLocalDateTime() + } catch (_: DateTimeParseException) { + LocalDateTime.parse(value) + } + + private fun logExternalApiError( + platform: ContentItemDTO, + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + ex: WebClientException + ) { + val platformName = platform.data?.name + + if (ex is WebClientResponseException) { + log.warn( + "Skipping spacecraft due to external API response error. name={}, noradId={}, timeStart={}, timeStop={}, status={}, responseBody={}", + platformName, + satelliteId, + timeStart, + timeStop, + ex.statusCode.value(), + ex.responseBodyAsString + ) + return + } + + log.warn( + "Skipping spacecraft due to external API transport error. name={}, noradId={}, timeStart={}, timeStop={}, message={}", + platformName, + satelliteId, + timeStart, + timeStop, + ex.message + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt new file mode 100644 index 0000000..8981fb9 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp_tgu_service.integration.api + +/** Вызывает сервис классификатора и возвращает сырые метаданные платформ. */ +import space.nstart.pcp_tgu_service.config.ClassifierProperties +import space.nstart.pcp_tgu_service.dto.PlatformsResponseDTO +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient + +/** + * HTTP-клиент для получения списка космических аппаратов из классификатора. + */ +@Component +class PlatformsClassifierClient( + private val classifierProperties: ClassifierProperties +) { + + private val webClient: WebClient = WebClient.builder() + .baseUrl(classifierProperties.platformsUrl) + .build() + + /** + * Загружает полный ответ классификатора платформ. + */ + /** Загружает полный ответ классификатора вместе с содержимым платформ. */ + fun fetchPlatforms(): PlatformsResponseDTO = + webClient.get() + .retrieve() + .bodyToMono(PlatformsResponseDTO::class.java) + .block() + ?: throw IllegalStateException("Classifier response body is empty: ${classifierProperties.platformsUrl}") +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/SpacecraftPointsKafkaConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/SpacecraftPointsKafkaConsumer.kt new file mode 100644 index 0000000..297d530 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/SpacecraftPointsKafkaConsumer.kt @@ -0,0 +1,47 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +/** Принимает обновления окон видимости КА из Kafka и передаёт их в планирование. */ +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage +import space.nstart.pcp_tgu_service.service.SpacecraftPointsService +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component + +/** + * Kafka consumer для обработки обновлений пунктов наблюдения. + * + * Каждое сообщение содержит данные по одному космическому аппарату. + */ +@Component +class SpacecraftPointsKafkaConsumer( + /** Имена Kafka-топиков из конфигурации. */ + val topicsProperties: TopicsProperties, + + /** Сервис обработки входных данных */ + private val spacecraftPointsService: SpacecraftPointsService +) { + + /** Логгер */ + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Обрабатывает сообщение из Kafka. + * + * @param message обновление пунктов по одному КА. + */ + /** Обрабатывает одно Kafka-сообщение с окнами видимости для КА. */ + @KafkaListener( + topics = ["#{__listener.topicsProperties.spacecraftPointsUpdate}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: SpacecraftPointsMessage) { + + log.info( + "Received spacecraft points update from Kafka. spacecraftId={}", + message.spacecraftId + ) + + spacecraftPointsService.saveFromMessage(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRepository.kt new file mode 100644 index 0000000..9baf7b3 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRepository.kt @@ -0,0 +1,77 @@ +package space.nstart.pcp_tgu_service.repository + +import space.nstart.pcp_tgu_service.domain.Plan +import org.springframework.stereotype.Repository +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory репозиторий планов. + * + * Хранит рассчитанные планы по каждому космическому аппарату. + */ +@Repository +class PlanRepository { + + /** + * Внутреннее потокобезопасное хранилище. + * + * Ключ — spacecraftId. + * Значение — список планов по этому КА. + */ + private val storage: MutableMap> = ConcurrentHashMap() + + /** + * Полностью заменяет список планов по одному КА. + * + * Используется после пересчёта планов. + * + * @param spacecraftId идентификатор космического аппарата. + * @param plans новый список планов. + */ + fun replacePlans(spacecraftId: String, plans: List) { + storage[spacecraftId] = plans + .sortedBy { plan -> plan.startTime } + .toMutableList() + } + + /** + * Возвращает все планы по всем КА. + * + * @return общий отсортированный список планов. + */ + fun findAll(): List = + storage.values + .flatten() + .sortedBy { plan -> plan.startTime } + + /** + * Возвращает список планов по одному КА. + * + * @param spacecraftId идентификатор космического аппарата. + * @return список планов указанного КА. + */ + fun findBySpacecraftId(spacecraftId: String): List = + storage[spacecraftId] + ?.sortedBy { plan -> plan.startTime } + ?: emptyList() + + /** + * Обновляет конкретный план. + * + * План ищется по spacecraftId и startTime. + * + * @param spacecraftId идентификатор космического аппарата. + * @param updatedPlan обновлённый план. + */ + fun updatePlan(spacecraftId: String, updatedPlan: Plan) { + val plans: MutableList = storage[spacecraftId] ?: return + + val index: Int = plans.indexOfFirst { existingPlan -> + existingPlan.startTime == updatedPlan.startTime + } + + if (index >= 0) { + plans[index] = updatedPlan + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt new file mode 100644 index 0000000..bf34647 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp_tgu_service.repository + +/** Предоставляет доступ к БД для сохранённого состояния планирования КА. */ +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface SpacecraftPlanningStateJpaRepository : JpaRepository diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPointsRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPointsRepository.kt new file mode 100644 index 0000000..4819dc0 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPointsRepository.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp_tgu_service.repository + +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import org.springframework.stereotype.Repository +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory репозиторий входных данных по космическим аппаратам. + * + * Хранит список доступных пунктов наблюдения для каждого КА. + */ +@Repository +class SpacecraftPointsRepository { + + /** + * Внутреннее потокобезопасное хранилище. + * + * Ключ — spacecraftId. + * Значение — все входные данные по конкретному КА. + */ + private val storage: MutableMap = ConcurrentHashMap() + + /** + * Сохраняет или обновляет данные по космическому аппарату. + * + * @param spacecraftPoints данные по одному КА. + */ + fun save(spacecraftPoints: SpacecraftPoints) { + storage[spacecraftPoints.spacecraftId] = spacecraftPoints + } + + /** + * Возвращает данные по одному КА. + * + * @param spacecraftId идентификатор космического аппарата. + * @return данные по КА или null, если их нет. + */ + fun findBySpacecraftId(spacecraftId: String): SpacecraftPoints? = + storage[spacecraftId] + + /** + * Возвращает данные по всем космическим аппаратам. + * + * @return список всех записей. + */ + fun findAll(): List = + storage.values.toList() +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt new file mode 100644 index 0000000..21a5d4b --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp_tgu_service.repository + +/** Предоставляет методы доступа к БД для отслеживаемых планов и их состояний. */ +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime +import java.util.UUID + +interface TrackedPlanJpaRepository : JpaRepository { + /** Возвращает отслеживаемые планы КА в указанном статусе. */ + fun findAllBySpacecraftIdAndStatus(spacecraftId: String, status: TrackedPlanStatus): List + + /** Возвращает все tracked-планы КА, упорядоченные по времени старта. */ + fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List + + /** Возвращает планы, подтверждённые с указанного момента, упорядоченные по времени старта. */ + fun findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime( + spacecraftId: String, + status: TrackedPlanStatus, + confirmedAt: LocalDateTime + ): List + + /** Возвращает отслеживаемый план по его бизнес-полям и статусу. */ + fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus( + spacecraftId: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String, + status: TrackedPlanStatus + ): TrackedPlanEntity? + + /** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */ + fun findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( + spacecraftId: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String + ): TrackedPlanEntity? + + /** Возвращает последний подтверждённый отслеживаемый план для КА. */ + fun findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc( + spacecraftId: String, + status: TrackedPlanStatus + ): TrackedPlanEntity? +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ExternalPointsUpdateScheduler.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ExternalPointsUpdateScheduler.kt new file mode 100644 index 0000000..53c6262 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ExternalPointsUpdateScheduler.kt @@ -0,0 +1,76 @@ +package space.nstart.pcp_tgu_service.service + +/** Периодически обновляет данные о видимости КА из внешнего API. */ +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service + +/** + * Планировщик обновления данных из внешнего API. + * + * После старта приложения выполняет первичную загрузку, + * а затем периодически обновляет данные по всем КА. + */ +@Service +class ExternalPointsUpdateScheduler( + /** Конфигурация планирования */ + private val planningProperties: PlanningProperties, + + /** Клиент внешнего API */ + private val externalPointsClient: ExternalPointsClient, + + /** Сервис сохранения входных данных и пересчёта планов */ + private val spacecraftPointsService: SpacecraftPointsService +) { + + /** Логгер */ + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Первичная загрузка данных после старта приложения. + */ + /** Выполняет первую загрузку при старте, если периодические обновления включены. */ + @PostConstruct + fun initLoad() { + if (!planningProperties.externalUpdateEnabled) { + log.info("External update is disabled. Initial load skipped.") + return + } + + try { + refreshFromExternalApi() + } catch (ex: Exception) { + log.error("Initial external refresh failed. Application startup will continue.", ex) + } + } + + /** + * Периодическое обновление данных из внешнего API. + */ + /** Выполняет периодическое обновление согласно настройкам планировщика. */ + @Scheduled(fixedDelayString = "\${planning.external-update-fixed-delay-ms}") + fun scheduledRefresh() { + if (!planningProperties.externalUpdateEnabled) { + return + } + + refreshFromExternalApi() + } + + /** + * Загружает данные из внешнего API и сохраняет их в систему. + */ + /** Загружает свежие окна видимости и передаёт их в конвейер планирования. */ + fun refreshFromExternalApi() { + val spacecraftPointsList = externalPointsClient.fetchAllSpacecraftPoints() + + spacecraftPointsList.forEach { spacecraftPoints -> + spacecraftPointsService.saveAndRecalculate(spacecraftPoints) + } + + log.info("External spacecraft points refresh completed. count={}", spacecraftPointsList.size) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt new file mode 100644 index 0000000..5a181bc --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -0,0 +1,287 @@ +package space.nstart.pcp_tgu_service.service + +/** Рассчитывает цепочки планов по окнам видимости КА с учётом целевой длительности и дельты. */ +import org.springframework.stereotype.Service +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.InsertionPoint +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import java.time.Duration +import java.time.LocalDateTime + +@Service +class PlanCalculationService( + private val planningProperties: PlanningProperties +) { + + /** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */ + private val overlapShiftByWindows: Int = 1 + + /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ + fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { + /** Все доступные кандидаты начала/окончания плана для указанного КА. */ + val candidates: List = buildCandidates(spacecraftPoints) + + return calculatePlansFromCandidates( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + initialStartIndex = 0 + ) + } + + /** + * Строит часть цепочки после уже известного плана. + * + * Если предыдущий план удалось сопоставить с текущими окнами наблюдения, + * следующий план начинается с нахлестом на одну ЗРВ. + * Если сопоставление не удалось, используется безопасный fallback без нахлеста. + */ + fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: Plan): List { + /** Все доступные кандидаты начала/окончания плана для указанного КА. */ + val candidates: List = buildCandidates(spacecraftPoints) + /** Индекс кандидата, с которого начинался предыдущий план. */ + val previousStartIndex: Int = candidates.indexOfFirst { candidate -> + candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime + } + /** Индекс кандидата, по которому заканчивался предыдущий план. */ + val previousEndIndex: Int = candidates.withIndex() + .firstOrNull { indexedCandidate -> + indexedCandidate.index > previousStartIndex && + indexedCandidate.value.endTime == previousPlan.endTime + } + ?.index + ?: -1 + + /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ + val initialStartIndex: Int = if ( + previousStartIndex >= 0 && + previousEndIndex > previousStartIndex + ) { + resolveNextStartIndex( + currentStartIndex = previousStartIndex, + endIndex = previousEndIndex + ) + } else { + candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime } + } + + if (initialStartIndex == -1) { + return emptyList() + } + + return calculatePlansFromCandidates( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + initialStartIndex = initialStartIndex + ) + } + + /** + * Возвращает план, который начинается строго с ближайшей закладки не раньше `fromTime`. + * + * Сервис не перескакивает на более позднюю закладку. + * Если ближайшая не попадает в целевое окно, всё равно выбирается + * лучший доступный конец, наиболее близкий к целевой длительности. + */ + fun calculateNearestPlanFrom(spacecraftPoints: SpacecraftPoints, fromTime: LocalDateTime): Plan? { + /** Все доступные кандидаты начала/окончания плана для указанного КА. */ + val candidates: List = buildCandidates(spacecraftPoints) + /** Индекс ближайшей закладки, которая начинается не раньше указанного времени. */ + val nearestStartIndex: Int = candidates.indexOfFirst { candidate -> candidate.startTime >= fromTime } + + if (nearestStartIndex == -1 || nearestStartIndex >= candidates.lastIndex) { + return null + } + + /** Индекс лучшего кандидата окончания для ближайшей закладки. */ + val endIndex: Int = findEndCandidateIndexOrNull( + candidates = candidates, + startIndex = nearestStartIndex + ) ?: return null + + return candidates[nearestStartIndex].toPlan( + spacecraftId = spacecraftPoints.spacecraftId, + endTime = candidates[endIndex].endTime + ) + } + + /** Преобразует отсортированных кандидатов в последовательную цепочку планов. */ + private fun calculatePlansFromCandidates( + spacecraftId: String, + candidates: List, + initialStartIndex: Int + ): List { + if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) { + return emptyList() + } + + /** Результирующая цепочка планов. */ + val resultPlans: MutableList = mutableListOf() + /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */ + var currentStartIndex: Int = initialStartIndex + + while (currentStartIndex < candidates.lastIndex) { + /** Текущий кандидат старта плана. */ + val currentCandidate: StartCandidate = candidates[currentStartIndex] + /** Индекс найденного кандидата окончания плана. */ + val endIndex: Int = findEndCandidateIndexOrNull( + candidates = candidates, + startIndex = currentStartIndex + ) ?: break + + resultPlans += currentCandidate.toPlan( + spacecraftId = spacecraftId, + endTime = candidates[endIndex].endTime + ) + + /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */ + val nextStartIndex: Int = resolveNextStartIndex( + currentStartIndex = currentStartIndex, + endIndex = endIndex + ) + + if (nextStartIndex <= currentStartIndex) { + break + } + + currentStartIndex = nextStartIndex + } + + return resultPlans + } + + /** Разворачивает все окна видимости в отсортированный список кандидатов начала и конца плана. */ + private fun buildCandidates(spacecraftPoints: SpacecraftPoints): List = + spacecraftPoints.points + .flatMap { point: InsertionPoint -> + point.observationWindows.map { observationWindow -> + StartCandidate( + pointId = point.pointId, + startTime = observationWindow.startTime, + endTime = observationWindow.endTime + ) + } + } + .sortedBy { candidate -> candidate.startTime } + + /** Возвращает целевую длительность плана из настроек. */ + private fun targetDuration(): Duration = + Duration.ofMinutes(planningProperties.targetPlanDurationMinutes) + + /** Возвращает начальный допуск вокруг целевой длительности плана. */ + private fun initialDelta(): Duration = + Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) + + /** + * Возвращает безопасный индекс старта следующего плана. + * + * Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего. + * Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат, + * чтобы не зациклиться и не повторить тот же самый план. + */ + private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int = + maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows) + + /** + * Находит лучший конечный кандидат для плана, начинающегося с `startIndex`. + * + * Сначала сервис старается попасть в целевое окно `target ± delta`. + * Если это невозможно, он всё равно обязан построить план и берёт + * любой доступный конец, наиболее близкий к целевой длительности. + */ + private fun findEndCandidateIndexOrNull( + candidates: List, + startIndex: Int + ): Int? { + /** Кандидат, с которого начинается рассматриваемый план. */ + val startCandidate: StartCandidate = candidates[startIndex] + /** Все доступные кандидаты окончания после выбранного старта. */ + val allEndCandidates: List> = candidates.withIndex() + .filter { indexedCandidate -> indexedCandidate.index > startIndex } + + if (allEndCandidates.isEmpty()) { + return null + } + + /** Допуск относительно целевой длительности плана. */ + val currentDelta: Duration = initialDelta() + /** Все кандидаты окончания, попавшие в настроенное окно допуска. */ + val matchingCandidates: List> = allEndCandidates + .filter { indexedCandidate -> + isWithinTargetWindow( + startTime = startCandidate.startTime, + candidateTime = indexedCandidate.value.endTime, + delta = currentDelta + ) + } + + return selectPreferredEndCandidate( + candidates = if (matchingCandidates.isNotEmpty()) matchingCandidates else allEndCandidates, + startTime = startCandidate.startTime + )?.index + } + + /** Проверяет, попадает ли длительность до конца кандидата в текущее окно допуска. */ + private fun isWithinTargetWindow( + startTime: LocalDateTime, + candidateTime: LocalDateTime, + delta: Duration + ): Boolean { + /** Фактическая длительность плана от текущего старта до рассматриваемого конца. */ + val duration: Duration = Duration.between(startTime, candidateTime) + /** Минимально допустимая длительность плана. */ + val minDuration: Duration = targetDuration().minus(delta) + /** Максимально допустимая длительность плана. */ + val maxDuration: Duration = targetDuration().plus(delta) + return duration >= minDuration && duration <= maxDuration + } + + /** + * Выбирает лучший конец по минимальному отклонению от целевой длительности. + * + * При равной близости предпочтение отдаётся более длинному плану, + * чтобы не занижать результат без необходимости. + */ + private fun selectPreferredEndCandidate( + candidates: List>, + startTime: LocalDateTime + ): IndexedValue? { + /** Целевая длительность плана из настроек. */ + val targetDuration: Duration = targetDuration() + + return candidates.minWithOrNull( + compareBy> { candidate -> + durationDistance( + first = Duration.between(startTime, candidate.value.endTime), + second = targetDuration + ) + }.thenByDescending { candidate -> + Duration.between(startTime, candidate.value.endTime) + } + ) + } + + /** Возвращает модуль разницы между двумя длительностями. */ + private fun durationDistance(first: Duration, second: Duration): Duration = + first.minus(second).abs() + + private data class StartCandidate( + /** Идентификатор пункта закладки, которому принадлежит окно. */ + val pointId: String, + + /** Время начала окна наблюдения. */ + val startTime: LocalDateTime, + + /** Время окончания окна наблюдения. */ + val endTime: LocalDateTime + ) { + /** Преобразует кандидата в доменный план с заданным временем окончания. */ + fun toPlan(spacecraftId: String, endTime: LocalDateTime): Plan = + Plan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = pointId + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationService.kt new file mode 100644 index 0000000..e793bee --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationService.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp_tgu_service.service + +/** Фиксирует подтверждение плана только в tracked-хранилище. */ +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class PlanConfirmationService( + private val trackedPlanService: TrackedPlanService +) { + + /** Подтверждает план и сохраняет факт подтверждения в БД без дополнительной обработки. */ + @Transactional + fun confirm(planId: UUID) = trackedPlanService.confirm(planId) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProcessScheduler.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProcessScheduler.kt new file mode 100644 index 0000000..bb43a21 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProcessScheduler.kt @@ -0,0 +1,58 @@ +package space.nstart.pcp_tgu_service.service + +/** Периодически запускает BPMN-процессы для планов, приближающихся к старту. */ +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.repository.PlanRepository +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class PlanProcessScheduler( + private val planningProperties: PlanningProperties, + private val planRepository: PlanRepository, + private val trackedPlanService: TrackedPlanService, + private val satelliteMissionProcessService: SatelliteMissionProcessService +) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** Просматривает ближайшие планы и запускает процессы для тех, что вошли в окно уведомления. */ + @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") + fun startProcessesForUpcomingPlans() { + val now = LocalDateTime.now() + val thresholdMinutes = planningProperties.notificationBeforeStartMinutes + + planRepository.findAll() + .filter { plan -> shouldStartProcess(plan, now, thresholdMinutes) } + .forEach { plan -> + try { + satelliteMissionProcessService.startCreateSatelliteMission(plan) + } catch (ex: Exception) { + log.warn( + "Failed to auto-start process for planId={}, spacecraftId={}", + plan.planId, + plan.spacecraftId, + ex + ) + } + } + } + + /** Проверяет, нужно ли в текущий момент ещё запускать процесс для плана. */ + private fun shouldStartProcess(plan: Plan, now: LocalDateTime, thresholdMinutes: Long): Boolean { + if (trackedPlanService.findConfirmedPlan(plan) != null) { + return false + } + + val trackedPlan = trackedPlanService.findByPlanId(plan.planId) + if (trackedPlan?.processInstanceKey != null) { + return false + } + + val startWindow = plan.startTime.minusMinutes(thresholdMinutes) + return now >= startWindow && now < plan.startTime + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt new file mode 100644 index 0000000..505f6be --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -0,0 +1,112 @@ +package space.nstart.pcp_tgu_service.service + +/** Формирует представление планов для API, объединяя историю из tracked-хранилища и хвост из памяти. */ +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import space.nstart.pcp_tgu_service.repository.PlanRepository +import java.time.LocalDateTime +import java.util.UUID + +@Service +class PlanQueryService( + private val planRepository: PlanRepository, + private val trackedPlanService: TrackedPlanService +) { + + /** Возвращает планы по всем КА, известным репозиторию планов в памяти. */ + fun getAllPlans(historyDays: Long): List = + ( + planRepository.findAll().map { plan -> plan.spacecraftId } + + trackedPlanService.getAllTrackedSpacecraftIds() + ) + .distinct() + .flatMap { spacecraftId -> getPlansBySpacecraftId(spacecraftId, historyDays) } + .sortedBy { plan -> plan.plan.startTime } + + /** Возвращает подтверждённую историю и будущие планы для указанного КА. */ + fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { + /** Текущий момент времени для отсечения прошлого и будущего. */ + val now: LocalDateTime = LocalDateTime.now() + /** Нормализованное количество дней истории, которое не может быть отрицательным. */ + val normalizedHistoryDays: Long = historyDays.coerceAtLeast(0) + val historySince = now.minusDays(normalizedHistoryDays) + /** Tracked-планы, которые уже участвуют в пользовательской выдаче. */ + val trackedPlansForView: List = trackedPlanService.getTrackedPlansBySpacecraftId(spacecraftId) + .filter { trackedPlan -> shouldIncludeInView(trackedPlan, now, historySince) } + .map { trackedPlan -> PlanView(plan = trackedPlan.toPlan(), status = trackedPlan.toViewStatus()) } + /** Набор tracked planId, чтобы не дублировать их in-memory планами. */ + val trackedPlanIds: Set = trackedPlansForView.map { planView -> planView.plan.planId }.toSet() + /** Будущие планы из памяти, которые ещё не вошли в tracked-хранилище. */ + val futureNewPlans: List = planRepository.findBySpacecraftId(spacecraftId) + .filter { plan -> plan.planId !in trackedPlanIds && plan.endTime >= now } + .map { plan -> PlanView(plan = plan, status = TrackedPlanStatus.NEW) } + + return (trackedPlansForView + futureNewPlans).sortedBy { planView -> planView.plan.startTime } + } + + /** Возвращает ближайший будущий план для указанного КА или выбрасывает исключение, если его нет. */ + fun getNearestFuturePlan(spacecraftId: String, now: LocalDateTime = LocalDateTime.now()): Plan = + planRepository.findBySpacecraftId(spacecraftId) + .filter { plan -> plan.startTime >= now } + .minByOrNull { plan -> plan.startTime } + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Nearest future plan for spacecraftId=$spacecraftId was not found" + ) + + /** + * Пытается переиспользовать уже существующий planId для плана с теми же бизнес-полями. + * + * Это защищает ручной запрос от размножения дубликатов, + * если один и тот же план запрашивают повторно. + */ + fun reuseExistingPlan(plan: Plan): Plan { + /** План из in-memory хранилища с теми же бизнес-полями, если он уже был рассчитан ранее. */ + val inMemoryPlan: Plan? = planRepository.findBySpacecraftId(plan.spacecraftId) + .firstOrNull { existingPlan -> + existingPlan.startTime == plan.startTime && + existingPlan.endTime == plan.endTime && + existingPlan.kppId == plan.kppId + } + + if (inMemoryPlan != null) { + return inMemoryPlan + } + + /** Последний tracked-план с теми же бизнес-полями, если такой уже создавался ранее. */ + val trackedPlan: Plan? = trackedPlanService.findLatestPlanByBusinessFields(plan) + return trackedPlan ?: plan + } + + data class PlanView( + val plan: Plan, + val status: TrackedPlanStatus + ) + + private fun shouldIncludeInView( + trackedPlan: TrackedPlanEntity, + now: LocalDateTime, + historySince: LocalDateTime + ): Boolean = + when (trackedPlan.status) { + TrackedPlanStatus.NEW -> trackedPlan.endTime >= now + TrackedPlanStatus.CONFIRMED -> trackedPlan.confirmedAt?.let { it >= historySince } ?: false + TrackedPlanStatus.WAITING_CONFIRMATION, + TrackedPlanStatus.SUPERSEDED -> trackedPlan.endTime >= now + } + + private fun TrackedPlanEntity.toViewStatus(): TrackedPlanStatus = status + + private fun TrackedPlanEntity.toPlan(): Plan = + Plan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + planId = planId + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorker.kt new file mode 100644 index 0000000..0f13711 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorker.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp_tgu_service.service + +import io.camunda.client.annotation.JobWorker +import io.camunda.client.api.response.ActivatedJob +import io.camunda.client.api.worker.JobClient +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class PlanStatusWorker( + private val planConfirmationService: PlanConfirmationService +) { + + private val log = LoggerFactory.getLogger(javaClass) + + @JobWorker(type = "updatePlanStatus", autoComplete = false) + fun updatePlanStatus(client: JobClient, job: ActivatedJob) { + val planId = job.variablesAsMap["satellitePlanId"]?.toString() + ?.takeIf { it.isNotBlank() } + ?.let(UUID::fromString) + ?: throw IllegalArgumentException("Camunda job variable satellitePlanId is missing or blank") + + planConfirmationService.confirm(planId) + + client.newCompleteCommand(job.key) + .send() + .join() + + log.info("Tracked plan marked as confirmed. planId={}, jobKey={}", planId, job.key) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt new file mode 100644 index 0000000..2e29fb8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt @@ -0,0 +1,29 @@ +package space.nstart.pcp_tgu_service.service + +/** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */ +import space.nstart.pcp_tgu_service.dto.ContentItemDTO +import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient +import org.springframework.stereotype.Service + +@Service +class PlatformService( + private val platformsClassifierClient: PlatformsClassifierClient +) { + /** Возвращает платформы из классификатора с необязательной фильтрацией по коду типа и статусу. */ + fun loadPlatforms( + platformTypeCode: String? = null, + status: String? = null + ): List { + + val platforms = platformsClassifierClient.fetchPlatforms().content.orEmpty() + + return platforms.filter { platform -> + val matchesPlatformTypeCode = platformTypeCode.isNullOrBlank() || + platform.data?.platformTypeCode == platformTypeCode + val matchesStatus = status.isNullOrBlank() || + platform.status == status + + matchesPlatformTypeCode && matchesStatus + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt new file mode 100644 index 0000000..add14a9 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt @@ -0,0 +1,174 @@ +package space.nstart.pcp_tgu_service.service + +/** Запускает и отслеживает Camunda-процесс, который формирует и отправляет спутниковый план. */ +import io.camunda.client.CamundaClient +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import java.time.LocalDateTime +import java.util.UUID + +@Service +class SatelliteMissionProcessService( + private val camundaClient: CamundaClient, + private val platformService: PlatformService, + private val spacecraftPointsService: SpacecraftPointsService, + private val planCalculationService: PlanCalculationService, + private val planQueryService: PlanQueryService, + private val trackedPlanService: TrackedPlanService +) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Ручной запрос плана. + * + * План строится строго от ближайшей закладки, начиная от текущего момента, + * с подбором конца только в пределах целевой длительности и настроенной дельты. + */ + fun startCreateSatelliteMission(noradId: Long): ProcessStartResponse { + return startCreateSatelliteMission(noradId, StartMode.AUTO) + } + + /** Ручной запрос плана с принудительной перевыдачей нового planId. */ + fun startCreateSatelliteMissionManually(noradId: Long): ProcessStartResponse { + return startCreateSatelliteMission(noradId, StartMode.MANUAL) + } + + private fun startCreateSatelliteMission(noradId: Long, startMode: StartMode): ProcessStartResponse { + ensurePlatformExists(noradId) + + /** Идентификатор КА в строковом виде, используемый внутри сервиса. */ + val spacecraftId: String = noradId.toString() + /** Текущее время, относительно которого ищется ближайшая закладка. */ + val now: LocalDateTime = LocalDateTime.now() + /** Сохраненные окна наблюдения для указанного КА. */ + val spacecraftPoints = spacecraftPointsService.getSpacecraftPointsBySpacecraftId(spacecraftId) + /** План, построенный строго от ближайшей закладки. */ + val calculatedPlan: Plan = planCalculationService.calculateNearestPlanFrom(spacecraftPoints, now) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Plan from nearest insertion point for spacecraftId=$spacecraftId was not found" + ) + /** План с переиспользованным planId, если такой бизнес-план уже существует в памяти или в tracked-хранилище. */ + val requestedPlan: Plan = when (startMode) { + StartMode.AUTO -> planQueryService.reuseExistingPlan(calculatedPlan) + StartMode.MANUAL -> calculatedPlan.copy(planId = UUID.randomUUID()) + } + + return startCreateSatelliteMission(requestedPlan, startMode) + } + + /** Запускает процесс для указанного плана и сохраняет ключ созданного экземпляра процесса. */ + fun startCreateSatelliteMission(plan: Plan): ProcessStartResponse { + return startCreateSatelliteMission(plan, StartMode.AUTO) + } + + private fun startCreateSatelliteMission(plan: Plan, startMode: StartMode): ProcessStartResponse { + /** Идентификатор КА, для которого запускается процесс. */ + val spacecraftId: String = plan.spacecraftId + /** NORAD-идентификатор, который требуется для проверки существования платформы. */ + val noradId: Long = spacecraftId.toLongOrNull() + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "spacecraftId=$spacecraftId cannot be converted to noradId" + ) + + ensurePlatformExists(noradId) + + /** Полный набор точек наблюдения, передаваемый в процесс создания миссии. */ + val message = spacecraftPointsService.getMessageBySpacecraftId(spacecraftId) + + when (startMode) { + StartMode.AUTO -> { + // Повторная отправка допустима, пока план не подтвержден. + // Блокируем только запуск процесса для уже подтвержденного плана. + trackedPlanService.findConfirmedPlan(plan)?.let { confirmedPlan -> + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Plan with planId=${confirmedPlan.planId} is already confirmed and cannot be started again for spacecraftId=$spacecraftId" + ) + } + } + StartMode.MANUAL -> { + trackedPlanService.findLatestTrackedPlanByBusinessFields(plan) + ?.takeIf { trackedPlan -> trackedPlan.status != space.nstart.pcp_tgu_service.entity.TrackedPlanStatus.SUPERSEDED } + ?.let { existingPlan -> + trackedPlanService.markSuperseded(existingPlan.planId, plan.planId) + } + } + } + + trackedPlanService.register( + plan = plan, + source = when (startMode) { + StartMode.AUTO -> space.nstart.pcp_tgu_service.entity.TrackedPlanSource.AUTO_CHAIN + StartMode.MANUAL -> space.nstart.pcp_tgu_service.entity.TrackedPlanSource.MANUAL + } + ) + + /** Набор process variables для BPMN-процесса. */ + val processVariables: CreateSatelliteMissionVariables = CreateSatelliteMissionVariables( + spacecraftId = message.spacecraftId, + satellitePlanId = plan.planId.toString(), + planStartTime = plan.startTime, + planEndTime = plan.endTime, + kppId = plan.kppId + ) + + /** Событие созданного экземпляра процесса, возвращаемое Camunda. */ + val processInstance = camundaClient + .newCreateInstanceCommand() + .bpmnProcessId(CREATE_SATELLITE_MISSION_PROCESS_ID) + .latestVersion() + .variables(processVariables) + .send() + .join() + + trackedPlanService.attachProcessInstance(plan.planId, processInstance.processInstanceKey) + + log.info( + "Started BPMN process. bpmnProcessId={}, processInstanceKey={}, spacecraftId={}, planId={}", + processInstance.bpmnProcessId, + processInstance.processInstanceKey, + message.spacecraftId, + plan.planId + ) + + return ProcessStartResponse( + processInstanceKey = processInstance.processInstanceKey, + processDefinitionKey = processInstance.processDefinitionKey, + bpmnProcessId = processInstance.bpmnProcessId, + version = processInstance.version, + planId = plan.planId + ) + } + + /** Проверяет, что КА присутствует во внешнем классификаторе платформ. */ + private fun ensurePlatformExists(noradId: Long) { + /** Признак того, что платформа с указанным NORAD-идентификатором существует во внешнем классификаторе. */ + val platformExists: Boolean = platformService.loadPlatforms() + .any { platform -> platform.data?.noradId == noradId } + + if (!platformExists) { + throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Platform with noradId=$noradId was not found" + ) + } + } + + /** Содержит BPMN-идентификаторы, используемые этим сервисом. */ + private companion object { + const val CREATE_SATELLITE_MISSION_PROCESS_ID = "createSatelliteMission" + } + + private enum class StartMode { + AUTO, + MANUAL + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPlanningStateService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPlanningStateService.kt new file mode 100644 index 0000000..1ea0c2d --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPlanningStateService.kt @@ -0,0 +1,32 @@ +package space.nstart.pcp_tgu_service.service + +/** Управляет сохранённым состоянием планирования для каждого КА. */ +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID + +@Service +class SpacecraftPlanningStateService( + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository +) { + + /** Возвращает идентификатор текущего подтверждённого базового плана для КА, если он есть. */ + @Transactional(readOnly = true) + fun getCurrentBasePlanId(spacecraftId: String): UUID? = + spacecraftPlanningStateJpaRepository.findById(spacecraftId) + .orElse(null) + ?.currentBasePlanId + + /** Сохраняет идентификатор текущего подтверждённого базового плана для КА. */ + @Transactional + fun setCurrentBasePlanId(spacecraftId: String, planId: UUID) { + val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId) + .orElse(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) + state.currentBasePlanId = planId + state.updatedAt = LocalDateTime.now() + spacecraftPlanningStateJpaRepository.save(state) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsService.kt new file mode 100644 index 0000000..c7cacb9 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsService.kt @@ -0,0 +1,136 @@ +package space.nstart.pcp_tgu_service.service + +/** Хранит данные видимости КА и перестраивает по ним текущую цепочку планов. */ +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.InsertionPoint +import space.nstart.pcp_tgu_service.domain.ObservationWindow +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.dto.ObservationWindowMessage +import space.nstart.pcp_tgu_service.dto.PointMessage +import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage +import space.nstart.pcp_tgu_service.repository.PlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository +import java.time.LocalDateTime + +@Service +class SpacecraftPointsService( + private val spacecraftPointsRepository: SpacecraftPointsRepository, + private val planRepository: PlanRepository, + private val planCalculationService: PlanCalculationService, + private val trackedPlanService: TrackedPlanService, + private val spacecraftPlanningStateService: SpacecraftPlanningStateService, + private val planningProperties: PlanningProperties +) { + + /** Сохраняет точки КА и сразу пересчитывает цепочку планов в памяти. */ + fun saveAndRecalculate(spacecraftPoints: SpacecraftPoints) { + spacecraftPointsRepository.save(spacecraftPoints) + rebuildActualPlans(spacecraftPoints.spacecraftId, spacecraftPoints) + } + + /** Перестраивает цепочку в памяти по уже сохранённым точкам указанного КА. */ + fun rebuildActualPlans(spacecraftId: String) { + /** Уже сохраненные точки наблюдения для указанного КА. */ + val spacecraftPoints: SpacecraftPoints = getSpacecraftPointsBySpacecraftId(spacecraftId) + rebuildActualPlans(spacecraftId, spacecraftPoints) + } + + /** Преобразует Kafka-сообщение в доменную модель, сохраняет его и перестраивает цепочку планов. */ + fun saveFromMessage(message: SpacecraftPointsMessage) { + /** Доменные данные по окнам наблюдения, собранные из входного Kafka-сообщения. */ + val domainModel: SpacecraftPoints = SpacecraftPoints( + spacecraftId = message.spacecraftId, + points = message.points.map { pointMessage -> + InsertionPoint( + pointId = pointMessage.pointId, + observationWindows = pointMessage.observationWindows.map { windowMessage -> + ObservationWindow( + startTime = windowMessage.startTime, + endTime = windowMessage.endTime, + elevationAngle = windowMessage.elevationAngle + ) + } + ) + } + ) + + saveAndRecalculate(domainModel) + } + + /** Возвращает сохранённые точки КА или выбрасывает `404 NOT_FOUND`, если их нет. */ + fun getSpacecraftPointsBySpacecraftId(spacecraftId: String): SpacecraftPoints = + spacecraftPointsRepository.findBySpacecraftId(spacecraftId) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Spacecraft points for spacecraftId=$spacecraftId were not found" + ) + + /** Преобразует сохранённые точки КА обратно в DTO-формат для Kafka. */ + fun getMessageBySpacecraftId(spacecraftId: String): SpacecraftPointsMessage { + /** Сохраненные точки наблюдения для указанного КА. */ + val spacecraftPoints: SpacecraftPoints = getSpacecraftPointsBySpacecraftId(spacecraftId) + + return SpacecraftPointsMessage( + spacecraftId = spacecraftPoints.spacecraftId, + points = spacecraftPoints.points.map { point -> + PointMessage( + pointId = point.pointId, + observationWindows = point.observationWindows.map { window -> + ObservationWindowMessage( + startTime = window.startTime, + endTime = window.endTime, + elevationAngle = window.elevationAngle + ) + } + ) + } + ) + } + + /** Перестраивает актуальные планы, объединяя подтверждённую историю с пересчитанным будущим хвостом. */ + private fun rebuildActualPlans(spacecraftId: String, spacecraftPoints: SpacecraftPoints) { + /** Текущий момент времени для отсечения исторической части. */ + val now: LocalDateTime = LocalDateTime.now() + /** Подтвержденные планы, которые еще нужно показывать как историю. */ + val confirmedHistory: List = trackedPlanService.getConfirmedPlansBySpacecraftIdSince( + spacecraftId = spacecraftId, + since = now.minusDays(planningProperties.inMemoryHistoryDays) + ) + /** Базовый план из legacy-состояния, если он ранее был зафиксирован в БД. */ + val basePlan: Plan? = spacecraftPlanningStateService.getCurrentBasePlanId(spacecraftId) + ?.let(trackedPlanService::findByPlanId) + ?.let { trackedPlan -> + Plan( + spacecraftId = trackedPlan.spacecraftId, + startTime = trackedPlan.startTime, + endTime = trackedPlan.endTime, + kppId = trackedPlan.kppId, + planId = trackedPlan.planId + ) + } + + /** Пересчитанный хвост будущих планов. */ + val futurePlans: List = if (basePlan == null) { + planCalculationService.calculatePlans(spacecraftPoints) + } else { + planCalculationService.calculatePlansAfter(spacecraftPoints, basePlan) + } + + /** Набор бизнес-ключей подтвержденных планов, чтобы не дублировать их в хвосте. */ + val confirmedKeys: Set = confirmedHistory.map { confirmedPlan -> confirmedPlan.toBusinessKey() }.toSet() + /** Итоговая объединенная цепочка для хранения в памяти. */ + val mergedPlans: List = confirmedHistory + futurePlans.filter { plan -> + plan.toBusinessKey() !in confirmedKeys + } + + planRepository.replacePlans(spacecraftId, mergedPlans) + } + + /** Строит стабильный бизнес-ключ для подавления дублей после пересчёта. */ + private fun Plan.toBusinessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TrackedPlanService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TrackedPlanService.kt new file mode 100644 index 0000000..bc9b23a --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TrackedPlanService.kt @@ -0,0 +1,179 @@ +package space.nstart.pcp_tgu_service.service + +/** Сохраняет и обновляет состояние жизненного цикла отслеживаемых планов в БД. */ +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import space.nstart.pcp_tgu_service.entity.TrackedPlanSource +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDateTime +import java.util.UUID + +@Service +class TrackedPlanService( + private val trackedPlanJpaRepository: TrackedPlanJpaRepository +) { + + /** Регистрирует план в хранилище отслеживания, если он ещё не был зарегистрирован. */ + @Transactional + fun register( + plan: Plan, + source: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN, + basePlanId: UUID? = null + ): TrackedPlanEntity = + trackedPlanJpaRepository.findById(plan.planId).orElseGet { + trackedPlanJpaRepository.save( + TrackedPlanEntity( + planId = plan.planId, + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + planSource = source, + basePlanId = basePlanId + ) + ) + } + + /** Привязывает ключ запущенного экземпляра процесса к отслеживаемому плану. */ + @Transactional + fun attachProcessInstance(planId: UUID, processInstanceKey: Long): TrackedPlanEntity { + val trackedPlan = getByPlanId(planId) + trackedPlan.processInstanceKey = processInstanceKey + return trackedPlanJpaRepository.save(trackedPlan) + } + + /** Помечает отслеживаемый план как подтверждённый и сохраняет время подтверждения. */ + @Transactional + fun confirm(planId: UUID): TrackedPlanEntity { + val trackedPlan = getByPlanId(planId) + if (trackedPlan.status == TrackedPlanStatus.CONFIRMED) { + return trackedPlan + } + + trackedPlan.status = TrackedPlanStatus.CONFIRMED + trackedPlan.confirmedAt = LocalDateTime.now() + return trackedPlanJpaRepository.save(trackedPlan) + } + + /** Помечает остальные ожидающие планы КА как вытесненные подтверждённым планом. */ + @Transactional + fun markWaitingPlansSuperseded(spacecraftId: String, supersededByPlanId: UUID) { + val waitingPlans = trackedPlanJpaRepository.findAllBySpacecraftIdAndStatus( + spacecraftId = spacecraftId, + status = TrackedPlanStatus.WAITING_CONFIRMATION + ).filter { trackedPlan -> trackedPlan.planId != supersededByPlanId } + + waitingPlans.forEach { trackedPlan -> + trackedPlan.status = TrackedPlanStatus.SUPERSEDED + trackedPlan.supersededByPlanId = supersededByPlanId + } + + trackedPlanJpaRepository.saveAll(waitingPlans) + } + + /** Ищет подтверждённый отслеживаемый план, совпадающий с переданным планом по бизнес-полям. */ + @Transactional(readOnly = true) + fun findConfirmedPlan(plan: Plan): TrackedPlanEntity? = + trackedPlanJpaRepository.findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus( + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + status = TrackedPlanStatus.CONFIRMED + ) + + /** Возвращает подтверждённые планы КА, начиная с указанного момента времени. */ + @Transactional(readOnly = true) + fun getConfirmedPlansBySpacecraftIdSince(spacecraftId: String, since: LocalDateTime): List = + trackedPlanJpaRepository.findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime( + spacecraftId = spacecraftId, + status = TrackedPlanStatus.CONFIRMED, + confirmedAt = since + ).map { it.toPlan() } + + /** Возвращает последний tracked-план с теми же бизнес-полями, если он уже существует. */ + @Transactional(readOnly = true) + fun findLatestPlanByBusinessFields(plan: Plan): Plan? = + trackedPlanJpaRepository.findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId + )?.toPlan() + + /** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */ + @Transactional(readOnly = true) + fun findLatestTrackedPlanByBusinessFields(plan: Plan): TrackedPlanEntity? = + trackedPlanJpaRepository.findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId + ) + + /** Возвращает все tracked-планы указанного КА. */ + @Transactional(readOnly = true) + fun getTrackedPlansBySpacecraftId(spacecraftId: String): List = + trackedPlanJpaRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) + + /** Возвращает идентификаторы КА, для которых есть tracked-планы. */ + @Transactional(readOnly = true) + fun getAllTrackedSpacecraftIds(): List = + trackedPlanJpaRepository.findAll() + .map { trackedPlan -> trackedPlan.spacecraftId } + .distinct() + + /** Помечает план как заменённый более новым планом. */ + @Transactional + fun markSuperseded(planId: UUID, supersededByPlanId: UUID): TrackedPlanEntity { + val trackedPlan = getByPlanId(planId) + if (trackedPlan.status == TrackedPlanStatus.SUPERSEDED && + trackedPlan.supersededByPlanId == supersededByPlanId + ) { + return trackedPlan + } + + trackedPlan.status = TrackedPlanStatus.SUPERSEDED + trackedPlan.supersededByPlanId = supersededByPlanId + return trackedPlanJpaRepository.save(trackedPlan) + } + + /** Возвращает последний подтверждённый план КА, если он существует. */ + @Transactional(readOnly = true) + fun getLatestConfirmedPlan(spacecraftId: String): Plan? = + trackedPlanJpaRepository.findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc( + spacecraftId = spacecraftId, + status = TrackedPlanStatus.CONFIRMED + )?.toPlan() + + /** Возвращает отслеживаемый план по идентификатору или `null`, если он отсутствует. */ + @Transactional(readOnly = true) + fun findByPlanId(planId: UUID): TrackedPlanEntity? = + trackedPlanJpaRepository.findById(planId).orElse(null) + + /** Возвращает отслеживаемый план по идентификатору или выбрасывает ошибку `404 NOT_FOUND`. */ + @Transactional(readOnly = true) + fun getByPlanId(planId: UUID): TrackedPlanEntity = + trackedPlanJpaRepository.findById(planId) + .orElseThrow { + ResponseStatusException( + HttpStatus.NOT_FOUND, + "Tracked plan with planId=$planId was not found" + ) + } + + /** Преобразует persistence-сущность отслеживаемого плана в доменную модель `Plan`. */ + private fun TrackedPlanEntity.toPlan(): Plan = + Plan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + planId = planId + ) +} diff --git a/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn b/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn new file mode 100644 index 0000000..5c8329f --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn @@ -0,0 +1,125 @@ + + + + + Flow_15fg4ts + + + + + + + + + + Flow_0m0ymgb + Flow_0r63sxk + + + + Flow_105c46r + + + + + + Flow_0r63sxk + Flow_15tmgj4 + + + + + + Flow_15tmgj4 + Flow_15fg4ts + + + + + + Flow_141jv88 + Flow_0m0ymgb + + + + + + Flow_0i87yhe + Flow_141jv88 + + + + + + Flow_105c46r + Flow_0i87yhe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml new file mode 100644 index 0000000..52c254f --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-tgu-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + uri: ${CONFIG_SERVER_URI:http://localhost:38888} + fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} + profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} + label: ${SPRING_CLOUD_CONFIG_LABEL:master} diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V1__create_tracked_plan.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V1__create_tracked_plan.sql new file mode 100644 index 0000000..721fc80 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V1__create_tracked_plan.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS tracked_plan ( + plan_id UUID PRIMARY KEY, + spacecraft_id VARCHAR(255) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + kpp_id VARCHAR(255) NOT NULL, + process_instance_key BIGINT, + created_at TIMESTAMP NOT NULL, + confirmed_at TIMESTAMP, + status VARCHAR(64) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tracked_plan_spacecraft_id ON tracked_plan(spacecraft_id); +CREATE INDEX IF NOT EXISTS idx_tracked_plan_status ON tracked_plan(status); diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V2__extend_tracked_plan.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V2__extend_tracked_plan.sql new file mode 100644 index 0000000..3e72ce7 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V2__extend_tracked_plan.sql @@ -0,0 +1,7 @@ +ALTER TABLE tracked_plan + ADD COLUMN IF NOT EXISTS plan_source VARCHAR(64) NOT NULL DEFAULT 'AUTO_CHAIN', + ADD COLUMN IF NOT EXISTS base_plan_id UUID, + ADD COLUMN IF NOT EXISTS superseded_by_plan_id UUID; + +CREATE INDEX IF NOT EXISTS idx_tracked_plan_base_plan_id ON tracked_plan(base_plan_id); +CREATE INDEX IF NOT EXISTS idx_tracked_plan_superseded_by_plan_id ON tracked_plan(superseded_by_plan_id); diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V3__create_spacecraft_planning_state.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V3__create_spacecraft_planning_state.sql new file mode 100644 index 0000000..b15ad81 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V3__create_spacecraft_planning_state.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS spacecraft_planning_state ( + spacecraft_id VARCHAR(255) PRIMARY KEY, + current_base_plan_id UUID, + updated_at TIMESTAMP NOT NULL +); diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt new file mode 100644 index 0000000..c129a64 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -0,0 +1,234 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.InsertionPoint +import space.nstart.pcp_tgu_service.domain.ObservationWindow +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import java.time.LocalDateTime + +class PlanCalculationServiceTest { + + @Test + fun `calculatePlans keeps neighbouring plans overlapped by one window when possible`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plans = service.calculatePlans( + spacecraftPoints( + window("KPP-1", start), + window("KPP-2", start.plusHours(8)), + window("KPP-3", start.plusHours(16)), + window("KPP-4", start.plusHours(23).plusMinutes(55), 15), + window("KPP-5", start.plusHours(24).plusMinutes(10)), + window("KPP-6", start.plusHours(32)), + window("KPP-7", start.plusHours(40)), + window("KPP-8", start.plusHours(47).plusMinutes(55), 15) + ) + ) + + assertEquals(4, plans.size) + assertEquals(start, plans[0].startTime) + assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) + assertEquals(start.plusHours(16), plans[1].startTime) + assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plans[1].endTime) + assertEquals("KPP-3", plans[1].kppId) + assertEquals(start.plusHours(32), plans[2].startTime) + assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime) + assertEquals(start.plusHours(40), plans[3].startTime) + assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime) + } + + @Test + fun `calculatePlansAfter keeps one window overlap with previous plan when it can be matched`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + val spacecraftPoints = spacecraftPoints( + window("KPP-1", start), + window("KPP-2", start.plusHours(8)), + window("KPP-3", start.plusHours(16)), + window("KPP-4", start.plusHours(23).plusMinutes(55), 15), + window("KPP-5", start.plusHours(24).plusMinutes(10)), + window("KPP-6", start.plusHours(32)), + window("KPP-7", start.plusHours(40)), + window("KPP-8", start.plusHours(47).plusMinutes(55), 15) + ) + val fullPlans = service.calculatePlans(spacecraftPoints) + + val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first()) + + assertEquals(3, plansAfterFirst.size) + assertEquals(start.plusHours(16), plansAfterFirst[0].startTime) + assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plansAfterFirst[0].endTime) + assertEquals("KPP-3", plansAfterFirst[0].kppId) + assertEquals(start.plusHours(32), plansAfterFirst[1].startTime) + assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime) + assertEquals(start.plusHours(40), plansAfterFirst[2].startTime) + assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[2].endTime) + } + + @Test + fun `calculatePlans returns empty list when only one window is available`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plans = service.calculatePlans( + spacecraftPoints( + window("KPP-1", start) + ) + ) + + assertTrue(plans.isEmpty()) + } + + @Test + fun `calculatePlans falls back to the closest end point when tail is outside delta`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plans = service.calculatePlans( + spacecraftPoints( + window("KPP-1", start), + window("KPP-2", start.plusHours(23).plusMinutes(55), 15), + window("KPP-3", start.plusHours(30)) + ) + ) + + assertEquals(2, plans.size) + assertEquals(start, plans[0].startTime) + assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) + assertEquals(start.plusHours(23).plusMinutes(55), plans[1].startTime) + assertEquals(start.plusHours(30).plusMinutes(10), plans[1].endTime) + } + + @Test + fun `calculateNearestPlanFrom uses nearest start and returns closest available end even outside delta`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plan = service.calculateNearestPlanFrom( + spacecraftPoints( + window("KPP-NEAREST", start), + window("KPP-SHORT-END", start.plusHours(2)), + window("KPP-LONG-END", start.plusDays(2)) + ), + fromTime = start + ) + + assertEquals(start, plan!!.startTime) + assertEquals(start.plusHours(2).plusMinutes(10), plan.endTime) + assertEquals("KPP-NEAREST", plan.kppId) + } + + @Test + fun `calculatePlans moves forward when overlap is impossible`() { + val service = createService(targetMinutes = 60L, deltaMinutes = 10L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plans = service.calculatePlans( + spacecraftPoints( + window("KPP-1", start), + window("KPP-2", start.plusMinutes(55), 15), + window("KPP-3", start.plusHours(1).plusMinutes(30), 35) + ) + ) + + assertEquals(2, plans.size) + assertEquals(start, plans[0].startTime) + assertEquals(start.plusMinutes(55), plans[1].startTime) + } + + @Test + fun `calculatePlans picks duration closest to target when windows are separated by large gaps`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plans = service.calculatePlans( + spacecraftPoints( + window("KPP-1", start), + window("KPP-2", start.plusHours(1).plusMinutes(30)), + window("KPP-3", start.plusDays(2)) + ) + ) + + assertEquals(2, plans.size) + assertEquals(start, plans[0].startTime) + assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime) + assertEquals(start.plusHours(1).plusMinutes(30), plans[1].startTime) + assertEquals(start.plusDays(2).plusMinutes(10), plans[1].endTime) + } + + + @Test + fun `calculatePlans chooses plan closest to target even when only short and long options exist`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + + val plans = service.calculatePlans( + spacecraftPoints( + window("KPP-1", start), + window("KPP-2", start.plusHours(1).plusMinutes(30)), + window("KPP-3", start.plusDays(2)) + ) + ) + + assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime) + } + + @Test + fun `calculatePlans handles ten simultaneous windows without looping`() { + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + val simultaneousWindows = buildList { + for (index in 1..10) { + add(window("SIM-$index", start)) + } + for (index in 1..10) { + add(window("END-$index", start.plusHours(23).plusMinutes(55), 15)) + } + } + + val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray())) + + assertTrue(plans.isNotEmpty()) + assertTrue(plans.size <= simultaneousWindows.size) + assertEquals(start, plans.first().startTime) + assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime) + } + + private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService = + PlanCalculationService( + PlanningProperties( + notificationBeforeStartMinutes = 30, + targetPlanDurationMinutes = targetMinutes, + targetPlanDurationDeltaMinutes = deltaMinutes, + externalUpdateEnabled = false, + externalPointsLookaheadDays = 3, + inMemoryHistoryDays = 14, + externalUpdateFixedDelayMs = 0, + notificationCheckFixedDelayMs = 30_000 + ) + ) + + private fun spacecraftPoints(vararg windows: Pair): SpacecraftPoints = + SpacecraftPoints( + spacecraftId = "25544", + points = windows + .groupBy({ it.first }, { it.second }) + .map { (pointId, observationWindows) -> + InsertionPoint( + pointId = pointId, + observationWindows = observationWindows + ) + } + ) + + private fun window(pointId: String, startTime: LocalDateTime, durationMinutes: Long = 10): Pair = + pointId to ObservationWindow( + startTime = startTime, + endTime = startTime.plusMinutes(durationMinutes), + elevationAngle = 10.0 + ) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationServiceTest.kt new file mode 100644 index 0000000..3c70232 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationServiceTest.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import java.time.LocalDateTime +import java.util.UUID + +class PlanConfirmationServiceTest { + + @Test + fun `confirm only persists confirmation in tracked storage`() { + val planId = UUID.randomUUID() + val confirmedEntity = TrackedPlanEntity( + planId = planId, + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 3, 10, 10, 0), + endTime = LocalDateTime.of(2026, 3, 11, 10, 0), + kppId = "KPP-1" + ) + val trackedPlanService = mock(TrackedPlanService::class.java) + + `when`(trackedPlanService.confirm(planId)).thenReturn(confirmedEntity) + + val service = PlanConfirmationService(trackedPlanService) + + val result = service.confirm(planId) + + assertEquals(planId, result.planId) + verify(trackedPlanService).confirm(planId) + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt new file mode 100644 index 0000000..ce89c37 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -0,0 +1,99 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import space.nstart.pcp_tgu_service.repository.PlanRepository +import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository +import java.time.LocalDateTime +import java.util.UUID + +class PlanQueryServiceTest { + + @Test + fun `getPlansBySpacecraftId returns statuses for new tracked and superseded plans`() { + val now = LocalDateTime.now() + val spacecraftId = "25544" + val newPlan = Plan( + spacecraftId = spacecraftId, + startTime = now.plusHours(4), + endTime = now.plusHours(5), + kppId = "KPP-NEW", + planId = UUID.randomUUID() + ) + val waitingTracked = trackedPlan( + spacecraftId = spacecraftId, + startTime = now.plusHours(1), + endTime = now.plusHours(2), + kppId = "KPP-WAITING", + status = TrackedPlanStatus.WAITING_CONFIRMATION + ) + val supersededTracked = trackedPlan( + spacecraftId = spacecraftId, + startTime = now.plusHours(2), + endTime = now.plusHours(3), + kppId = "KPP-SUPERSEDED", + status = TrackedPlanStatus.SUPERSEDED + ) + val confirmedTracked = trackedPlan( + spacecraftId = spacecraftId, + startTime = now.minusHours(2), + endTime = now.minusHours(1), + kppId = "KPP-CONFIRMED", + status = TrackedPlanStatus.CONFIRMED, + confirmedAt = now.minusDays(1) + ) + val planRepository = PlanRepository().apply { + replacePlans(spacecraftId, listOf(newPlan)) + } + val trackedPlanService = FixedTrackedPlanService( + spacecraftId = spacecraftId, + trackedPlans = listOf(confirmedTracked, waitingTracked, supersededTracked) + ) + val service = PlanQueryService(planRepository, trackedPlanService) + + val result = service.getPlansBySpacecraftId(spacecraftId, historyDays = 7) + + assertEquals( + listOf( + TrackedPlanStatus.CONFIRMED, + TrackedPlanStatus.WAITING_CONFIRMATION, + TrackedPlanStatus.SUPERSEDED, + TrackedPlanStatus.NEW + ), + result.map { planView -> planView.status } + ) + } + + private class FixedTrackedPlanService( + private val spacecraftId: String, + private val trackedPlans: List + ) : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) { + + override fun getTrackedPlansBySpacecraftId(spacecraftId: String): List = + if (spacecraftId == this.spacecraftId) trackedPlans else emptyList() + + override fun getAllTrackedSpacecraftIds(): List = listOf(spacecraftId) + } + + private fun trackedPlan( + spacecraftId: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String, + status: TrackedPlanStatus, + confirmedAt: LocalDateTime? = null + ): TrackedPlanEntity = + TrackedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + confirmedAt = confirmedAt, + status = status + ) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorkerTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorkerTest.kt new file mode 100644 index 0000000..088d189 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorkerTest.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp_tgu_service.service + +import io.camunda.client.api.CamundaFuture +import io.camunda.client.api.command.CompleteJobCommandStep1 +import io.camunda.client.api.response.ActivatedJob +import io.camunda.client.api.response.CompleteJobResponse +import io.camunda.client.api.worker.JobClient +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.util.UUID + +class PlanStatusWorkerTest { + + @Test + fun `updatePlanStatus confirms tracked plan and completes job`() { + val planId = UUID.randomUUID() + val job = mock(ActivatedJob::class.java) + val client = mock(JobClient::class.java) + val completeCommand = mock(CompleteJobCommandStep1::class.java) + val future = mock(CamundaFuture::class.java) as CamundaFuture + val planConfirmationService = mock(PlanConfirmationService::class.java) + + `when`(job.variablesAsMap).thenReturn(mapOf("satellitePlanId" to planId.toString())) + `when`(job.key).thenReturn(123L) + `when`(client.newCompleteCommand(123L)).thenReturn(completeCommand) + `when`(completeCommand.send()).thenReturn(future) + `when`(future.join()).thenReturn(null) + + val worker = PlanStatusWorker(planConfirmationService) + + worker.updatePlanStatus(client, job) + + verify(planConfirmationService).confirm(planId) + verify(client).newCompleteCommand(123L) + verify(completeCommand).send() + verify(future).join() + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt new file mode 100644 index 0000000..425a6f0 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt @@ -0,0 +1,309 @@ +package space.nstart.pcp_tgu_service.service + +import io.camunda.client.CamundaClient +import io.camunda.client.api.CamundaFuture +import io.camunda.client.api.command.CreateProcessInstanceCommandStep1 +import io.camunda.client.api.response.ProcessInstanceEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.dto.ContentDataDTO +import space.nstart.pcp_tgu_service.dto.ContentItemDTO +import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables +import space.nstart.pcp_tgu_service.dto.PointMessage +import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage +import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity +import space.nstart.pcp_tgu_service.entity.TrackedPlanSource +import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient +import space.nstart.pcp_tgu_service.repository.PlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository +import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository +import java.time.LocalDateTime +import java.util.UUID + +class SatelliteMissionProcessServiceTest { + + @Test + fun `startCreateSatelliteMission returns conflict for confirmed plan`() { + val noradId = 25544L + val spacecraftId = noradId.toString() + val confirmedPlan = Plan( + spacecraftId = spacecraftId, + startTime = LocalDateTime.of(2026, 3, 10, 10, 0), + endTime = LocalDateTime.of(2026, 3, 11, 10, 0), + kppId = "KPP-1", + planId = UUID.randomUUID() + ) + val planCalculationService = mock(PlanCalculationService::class.java) + val planQueryService = mock(PlanQueryService::class.java) + + `when`(planCalculationService.calculateNearestPlanFrom(anyNonNull(), anyNonNull())).thenReturn(confirmedPlan) + `when`(planQueryService.reuseExistingPlan(confirmedPlan)).thenReturn(confirmedPlan) + + val service = SatelliteMissionProcessService( + camundaClient = mock(CamundaClient::class.java), + platformService = FixedPlatformService(listOf(platform(noradId))), + spacecraftPointsService = FixedSpacecraftPointsService( + SpacecraftPointsMessage(spacecraftId, emptyList()) + ), + planCalculationService = planCalculationService, + planQueryService = planQueryService, + trackedPlanService = FixedTrackedPlanService( + confirmedPlan = TrackedPlanEntity( + planId = confirmedPlan.planId, + spacecraftId = confirmedPlan.spacecraftId, + startTime = confirmedPlan.startTime, + endTime = confirmedPlan.endTime, + kppId = confirmedPlan.kppId, + status = TrackedPlanStatus.CONFIRMED + ) + ) + ) + + val exception = assertThrows(ResponseStatusException::class.java) { + service.startCreateSatelliteMission(noradId) + } + + assertEquals(HttpStatus.CONFLICT, exception.statusCode) + } + + @Test + fun `startCreateSatelliteMissionManually supersedes confirmed plan and starts new process`() { + val noradId = 25544L + val spacecraftId = noradId.toString() + val confirmedPlan = Plan( + spacecraftId = spacecraftId, + startTime = LocalDateTime.of(2026, 3, 10, 10, 0), + endTime = LocalDateTime.of(2026, 3, 11, 10, 0), + kppId = "KPP-1", + planId = UUID.randomUUID() + ) + val pointsMessage = SpacecraftPointsMessage( + spacecraftId = spacecraftId, + points = listOf(PointMessage(pointId = "KPP-1", observationWindows = emptyList())) + ) + val planCalculationService = mock(PlanCalculationService::class.java) + val planQueryService = mock(PlanQueryService::class.java) + val camundaClient = mock(CamundaClient::class.java) + val createCommand = mock(CreateProcessInstanceCommandStep1::class.java) + val processStep2 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2::class.java) + val processStep3 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3::class.java) + val future = mock(CamundaFuture::class.java) as CamundaFuture + val processInstanceEvent = mock(ProcessInstanceEvent::class.java) + + `when`(planCalculationService.calculateNearestPlanFrom(anyNonNull(), anyNonNull())).thenReturn(confirmedPlan) + `when`(camundaClient.newCreateInstanceCommand()).thenReturn(createCommand) + `when`(createCommand.bpmnProcessId("createSatelliteMission")).thenReturn(processStep2) + `when`(processStep2.latestVersion()).thenReturn(processStep3) + `when`(processStep3.variables(anyNonNull())).thenReturn(processStep3) + `when`(processStep3.send()).thenReturn(future) + `when`(future.join()).thenReturn(processInstanceEvent) + `when`(processInstanceEvent.getProcessInstanceKey()).thenReturn(123L) + `when`(processInstanceEvent.getProcessDefinitionKey()).thenReturn(456L) + `when`(processInstanceEvent.getBpmnProcessId()).thenReturn("createSatelliteMission") + `when`(processInstanceEvent.getVersion()).thenReturn(1) + + val trackedPlanService = FixedTrackedPlanService( + latestTrackedPlan = TrackedPlanEntity( + planId = confirmedPlan.planId, + spacecraftId = confirmedPlan.spacecraftId, + startTime = confirmedPlan.startTime, + endTime = confirmedPlan.endTime, + kppId = confirmedPlan.kppId, + status = TrackedPlanStatus.CONFIRMED + ) + ) + val service = SatelliteMissionProcessService( + camundaClient = camundaClient, + platformService = FixedPlatformService(listOf(platform(noradId))), + spacecraftPointsService = FixedSpacecraftPointsService(pointsMessage), + planCalculationService = planCalculationService, + planQueryService = planQueryService, + trackedPlanService = trackedPlanService + ) + + val response = service.startCreateSatelliteMissionManually(noradId) + + assertEquals(123L, response.processInstanceKey) + assertEquals(confirmedPlan.planId, trackedPlanService.supersededPlanId) + assertEquals(response.planId, trackedPlanService.supersededByPlanId) + assertEquals(TrackedPlanSource.MANUAL, trackedPlanService.registeredSource) + } + + @Test + fun `startCreateSatelliteMission allows repeated start for waiting confirmation plan`() { + val noradId = 25544L + val spacecraftId = noradId.toString() + val waitingPlan = Plan( + spacecraftId = spacecraftId, + startTime = LocalDateTime.of(2026, 3, 10, 10, 0), + endTime = LocalDateTime.of(2026, 3, 11, 10, 0), + kppId = "KPP-1", + planId = UUID.randomUUID() + ) + val pointsMessage = SpacecraftPointsMessage( + spacecraftId = spacecraftId, + points = listOf(PointMessage(pointId = "KPP-1", observationWindows = emptyList())) + ) + val planCalculationService = mock(PlanCalculationService::class.java) + val planQueryService = mock(PlanQueryService::class.java) + + `when`(planCalculationService.calculateNearestPlanFrom(anyNonNull(), anyNonNull())).thenReturn(waitingPlan) + `when`(planQueryService.reuseExistingPlan(waitingPlan)).thenReturn(waitingPlan) + + val camundaClient = mock(CamundaClient::class.java) + val createCommand = mock(CreateProcessInstanceCommandStep1::class.java) + val processStep2 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2::class.java) + val processStep3 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3::class.java) + val future = mock(CamundaFuture::class.java) as CamundaFuture + val processInstanceEvent = mock(ProcessInstanceEvent::class.java) + + `when`(camundaClient.newCreateInstanceCommand()).thenReturn(createCommand) + `when`(createCommand.bpmnProcessId("createSatelliteMission")).thenReturn(processStep2) + `when`(processStep2.latestVersion()).thenReturn(processStep3) + `when`(processStep3.variables(anyNonNull())).thenReturn(processStep3) + `when`(processStep3.send()).thenReturn(future) + `when`(future.join()).thenReturn(processInstanceEvent) + `when`(processInstanceEvent.getProcessInstanceKey()).thenReturn(123L) + `when`(processInstanceEvent.getProcessDefinitionKey()).thenReturn(456L) + `when`(processInstanceEvent.getBpmnProcessId()).thenReturn("createSatelliteMission") + `when`(processInstanceEvent.getVersion()).thenReturn(1) + + val trackedPlanService = FixedTrackedPlanService() + val service = SatelliteMissionProcessService( + camundaClient = camundaClient, + platformService = FixedPlatformService(listOf(platform(noradId))), + spacecraftPointsService = FixedSpacecraftPointsService(pointsMessage), + planCalculationService = planCalculationService, + planQueryService = planQueryService, + trackedPlanService = trackedPlanService + ) + + val response = service.startCreateSatelliteMission(noradId) + + assertEquals(waitingPlan.planId, response.planId) + assertEquals(123L, response.processInstanceKey) + assertEquals(waitingPlan.planId, trackedPlanService.registeredPlan?.planId) + assertEquals(123L, trackedPlanService.attachedProcessInstanceKey) + verify(planCalculationService).calculateNearestPlanFrom(anyNonNull(), anyNonNull()) + verify(planQueryService).reuseExistingPlan(waitingPlan) + } + + private class FixedPlatformService( + private val platforms: List + ) : PlatformService(mock(PlatformsClassifierClient::class.java)) { + override fun loadPlatforms(platformTypeCode: String?, status: String?): List = platforms + } + + private class FixedSpacecraftPointsService( + private val message: SpacecraftPointsMessage + ) : SpacecraftPointsService( + mock(SpacecraftPointsRepository::class.java), + mock(PlanRepository::class.java), + mock(PlanCalculationService::class.java), + mock(TrackedPlanService::class.java), + mock(SpacecraftPlanningStateService::class.java), + PlanningProperties( + notificationBeforeStartMinutes = 30, + targetPlanDurationMinutes = 24 * 60L, + targetPlanDurationDeltaMinutes = 60L, + externalUpdateEnabled = false, + externalPointsLookaheadDays = 3, + inMemoryHistoryDays = 14, + externalUpdateFixedDelayMs = 0, + notificationCheckFixedDelayMs = 30_000 + ) + ) { + override fun getMessageBySpacecraftId(spacecraftId: String): SpacecraftPointsMessage = message + + override fun getSpacecraftPointsBySpacecraftId(spacecraftId: String): SpacecraftPoints = + SpacecraftPoints(spacecraftId = spacecraftId, points = emptyList()) + } + + private class FixedTrackedPlanService( + private val confirmedPlan: TrackedPlanEntity? = null, + private val latestTrackedPlan: TrackedPlanEntity? = null + ) : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) { + var registeredPlan: Plan? = null + var registeredSource: TrackedPlanSource? = null + var attachedProcessInstanceKey: Long? = null + var supersededPlanId: UUID? = null + var supersededByPlanId: UUID? = null + + override fun findConfirmedPlan(plan: Plan): TrackedPlanEntity? = confirmedPlan + + override fun findLatestTrackedPlanByBusinessFields(plan: Plan): TrackedPlanEntity? = latestTrackedPlan + + override fun register(plan: Plan, source: TrackedPlanSource, basePlanId: UUID?): TrackedPlanEntity { + registeredPlan = plan + registeredSource = source + return TrackedPlanEntity( + planId = plan.planId, + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId + ) + } + + override fun attachProcessInstance(planId: UUID, processInstanceKey: Long): TrackedPlanEntity { + attachedProcessInstanceKey = processInstanceKey + return TrackedPlanEntity( + planId = planId, + spacecraftId = registeredPlan!!.spacecraftId, + startTime = registeredPlan!!.startTime, + endTime = registeredPlan!!.endTime, + kppId = registeredPlan!!.kppId, + processInstanceKey = processInstanceKey + ) + } + + override fun markSuperseded(planId: UUID, supersededByPlanId: UUID): TrackedPlanEntity { + supersededPlanId = planId + this.supersededByPlanId = supersededByPlanId + val plan = latestTrackedPlan ?: error("latestTrackedPlan is required") + return TrackedPlanEntity( + planId = plan.planId, + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + status = TrackedPlanStatus.SUPERSEDED, + supersededByPlanId = supersededByPlanId + ) + } + } + + private fun platform(noradId: Long): ContentItemDTO = + ContentItemDTO( + uuid = UUID.randomUUID().toString(), + parentItemUuid = null, + status = "ACTIVE", + initiation = null, + expiration = null, + data = ContentDataDTO( + platformTypeCode = null, + noradId = noradId, + umn = null, + name = "satellite", + description = null, + platformTypeName = null + ) + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsServiceTest.kt new file mode 100644 index 0000000..c4cdcf6 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsServiceTest.kt @@ -0,0 +1,116 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.ObservationWindow +import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.domain.InsertionPoint +import space.nstart.pcp_tgu_service.repository.PlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository +import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository +import java.time.LocalDateTime +import java.util.UUID + +class SpacecraftPointsServiceTest { + + @Test + fun `saveAndRecalculate keeps confirmed plans in output after recalculation`() { + val spacecraftPointsRepository = mock(SpacecraftPointsRepository::class.java) + val planRepository = mock(PlanRepository::class.java) + val planCalculationService = mock(PlanCalculationService::class.java) + val planningProperties = PlanningProperties( + notificationBeforeStartMinutes = 30, + targetPlanDurationMinutes = 24 * 60L, + targetPlanDurationDeltaMinutes = 60L, + externalUpdateEnabled = false, + externalPointsLookaheadDays = 3, + inMemoryHistoryDays = 14, + externalUpdateFixedDelayMs = 0, + notificationCheckFixedDelayMs = 30_000 + ) + val spacecraftId = "25544" + val confirmedPlan = Plan( + spacecraftId = spacecraftId, + startTime = LocalDateTime.of(2026, 3, 10, 10, 0), + endTime = LocalDateTime.of(2026, 3, 11, 10, 0), + kppId = "KPP-1", + planId = UUID.randomUUID() + ) + val trackedPlanService = object : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) { + override fun getConfirmedPlansBySpacecraftIdSince(spacecraftId: String, since: LocalDateTime): List = + listOf(confirmedPlan) + } + val spacecraftPlanningStateService = + object : SpacecraftPlanningStateService(mock(SpacecraftPlanningStateJpaRepository::class.java)) { + override fun getCurrentBasePlanId(spacecraftId: String): UUID? = null + } + + val service = SpacecraftPointsService( + spacecraftPointsRepository = spacecraftPointsRepository, + planRepository = planRepository, + planCalculationService = planCalculationService, + trackedPlanService = trackedPlanService, + spacecraftPlanningStateService = spacecraftPlanningStateService, + planningProperties = planningProperties + ) + + val duplicateRecalculatedPlan = Plan( + spacecraftId = spacecraftId, + startTime = confirmedPlan.startTime, + endTime = confirmedPlan.endTime, + kppId = confirmedPlan.kppId, + planId = UUID.randomUUID() + ) + val newRecalculatedPlan = Plan( + spacecraftId = spacecraftId, + startTime = LocalDateTime.of(2026, 3, 11, 12, 0), + endTime = LocalDateTime.of(2026, 3, 12, 12, 0), + kppId = "KPP-2", + planId = UUID.randomUUID() + ) + val spacecraftPoints = SpacecraftPoints( + spacecraftId = spacecraftId, + points = listOf( + InsertionPoint( + pointId = "KPP-1", + observationWindows = listOf( + ObservationWindow( + startTime = confirmedPlan.startTime, + endTime = confirmedPlan.endTime, + elevationAngle = 10.0 + ) + ) + ) + ) + ) + + `when`(planCalculationService.calculatePlans(spacecraftPoints)) + .thenReturn(listOf(duplicateRecalculatedPlan, newRecalculatedPlan)) + var savedSpacecraftId: String? = null + var savedPlans: List = emptyList() + doAnswer { invocation -> + savedSpacecraftId = invocation.getArgument(0) + savedPlans = invocation.getArgument(1) + null + }.`when`(planRepository).replacePlans(anyString(), anyList()) + + service.saveAndRecalculate(spacecraftPoints) + + verify(planRepository).replacePlans(anyString(), anyList()) + assertEquals(spacecraftId, savedSpacecraftId) + assertEquals(2, savedPlans.size) + assertTrue(savedPlans.any { it.planId == confirmedPlan.planId }) + assertTrue(savedPlans.any { it.planId == newRecalculatedPlan.planId }) + assertTrue(savedPlans.none { it.planId == duplicateRecalculatedPlan.planId }) + } +} diff --git a/services/pcp-tgu-service/todo.txt b/services/pcp-tgu-service/todo.txt new file mode 100644 index 0000000..1b8644f --- /dev/null +++ b/services/pcp-tgu-service/todo.txt @@ -0,0 +1,6 @@ +1. Время окончания плана должно быть временем окончания зоны ЗРВ. +2. Реализовать worker подтверждения плана. После подтверждения плана от СПО УОГ необходимо записать эту инфу в БД. +3. Проверять работу КА, чтобы начало действия плана не начиналось в момент работы (ВОЗ, ЗАП). +Запрашивать план работы КА у планирования на интервал, и сопоставлять это с временами закладок. +4. Получение подтверждения от comunda и проставление в БД признака, что план подтвержден +5. Нужно ли проставлять признак, что план заложен? не просто подтвержден, а заложен \ No newline at end of file diff --git a/services/pcp-ui-service/Dockerfile b/services/pcp-ui-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/pcp-ui-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/pcp-ui-service/build.gradle.kts b/services/pcp-ui-service/build.gradle.kts new file mode 100644 index 0000000..333ab92 --- /dev/null +++ b/services/pcp-ui-service/build.gradle.kts @@ -0,0 +1,79 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +version = "1.0.0" + + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:ballistics-lib")) + implementation(project(":libs:pcp-types-lib")) + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("jakarta.validation:jakarta.validation-api") + implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") + implementation("org.springframework.boot:spring-boot-starter-web") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.cloud:spring-cloud-starter-config") + + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") + implementation("org.projectlombok:lombok") + + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6") + + implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.2.1") + implementation("org.webjars.npm:cesium:1.107.2") +// implementation("org.webjars.npm:cesium:1.116.0") + implementation("org.webjars:bootstrap:5.3.0") + implementation("org.webjars:jquery:3.6.2") + implementation("org.webjars:webjars-locator:0.45") + + + implementation("org.locationtech.jts:jts-core:1.19.0") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +tasks.withType { + useJUnitPlatform() +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/PcpUiServiceApplication.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/PcpUiServiceApplication.kt new file mode 100644 index 0000000..9f2d8ba --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/PcpUiServiceApplication.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.slots_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class PcpUiServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/CorsFilterComponent.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/CorsFilterComponent.kt new file mode 100644 index 0000000..49da575 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/CorsFilterComponent.kt @@ -0,0 +1,96 @@ +package space.nstart.pcp.slots_service.configuration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.filter.CorsFilter +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + + +@Component +class CorsFilterComponent : WebFilter { + + override fun filter(ctx: ServerWebExchange, chain: WebFilterChain): Mono { + if (ctx != null) { + ctx.response.headers.add("Access-Control-Allow-Origin", "*") + ctx.response.headers.add("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS") + ctx.response.headers.add( + "Access-Control-Allow-Headers", + "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range" + ) + ctx.response.headers.add( + "Access-Control-Expose-Headers", + "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range" + ) + return chain?.filter(ctx) ?: Mono.empty() + + } else { + return chain?.filter(ctx) ?: Mono.empty() + } + } +} + + + + +@Configuration +class WebConfig : WebMvcConfigurer { + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins( + "http://localhost:3000", + "http://localhost:8080", + "http://192.168.60.68:7008", + "http://192.168.60.68:8080" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600) + } + + // Альтернативный вариант через CorsFilter + @Bean + fun corsFilter(): CorsFilter { + val source = UrlBasedCorsConfigurationSource() + val config = CorsConfiguration().apply { + allowCredentials = true + // Разрешенные origin'ы + allowedOriginPatterns = listOf( + "http://localhost:*", + "http://192.168.60.68:*" + ) + // Разрешенные заголовки + allowedHeaders = listOf( + "Origin", + "Content-Type", + "Accept", + "Authorization", + "X-Requested-With", + "Access-Control-Request-Method", + "Access-Control-Request-Headers" + ) + // Разрешенные методы + allowedMethods = listOf( + "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD" + ) + // Заголовки, доступные клиенту + exposedHeaders = listOf( + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials" + ) + maxAge = 3600L + } + + source.registerCorsConfiguration("/**", config) + return CorsFilter(source) + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..7025c99 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.slots_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(WebExchangeBindException::class) + fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { + val errors = mutableMapOf() + ex.bindingResult.fieldErrors.forEach { error -> + errors[error.field] = error.defaultMessage ?: "Validation error" + } + return Mono.just(ResponseEntity.badRequest().body(errors)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/ValidationConfig.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/ValidationConfig.kt new file mode 100644 index 0000000..75886da --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/ValidationConfig.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.slots_service.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator { + return Validation.buildDefaultValidatorFactory().validator + } +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebClientConfig.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebClientConfig.kt new file mode 100644 index 0000000..47d7ae9 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebClientConfig.kt @@ -0,0 +1,28 @@ +package space.nstart.pcp.slots_service.configuration + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration + +@Configuration +class WebClientConfig( + @param:Value("\${settings.web-client.max-in-memory-size-bytes:134217728}") + private val maxInMemorySizeBytes: Int +) { + + @Bean + fun webClientBuilder(): WebClient.Builder { + val httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(230)) + + return WebClient.builder() + .clientConnector(ReactorClientHttpConnector(httpClient)) + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(maxInMemorySizeBytes) + } + } +} 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 new file mode 100644 index 0000000..106d8b7 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping + +@Controller +class CatalogPageController { + + @GetMapping("/groups") + fun groupsPage(): String = "groups" + + @GetMapping("/group-state") + fun groupStatePage(): String = "group-state" + + @GetMapping("/satellites") + fun satellitesPage(): String = "satellites" + + @GetMapping("/satellites/pdcm") + fun satellitesPdcmPage(): String = "satellites-pdcm" + + @GetMapping("/current-plans") + fun currentPlansPage(): String = "current-plans" +} 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 new file mode 100644 index 0000000..8a6d82d --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt @@ -0,0 +1,157 @@ +package space.nstart.pcp.slots_service.controller + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +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_types_lib.dto.ballistics.InitialConditionsDTO +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 +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +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.SatelliteCatalogService +import space.nstart.pcp.slots_service.service.SatellitePdcmService +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/catalog") +class CatalogRestController( + private val satelliteCatalogService: SatelliteCatalogService, + private val slotService: SlotService, + private val groupStateService: GroupStateService, + private val satellitePdcmService: SatellitePdcmService +) { + + @GetMapping("/satellites") + fun allSatellites() = satelliteCatalogService.allSatellites() + + @GetMapping("/satellites/slot-calculation-summaries") + fun satelliteSlotCalculationSummaries(@RequestParam satelliteIds: List) = + runCatching { slotService.slotCalculationSummaries(satelliteIds) } + .getOrElse { satelliteIds.distinct().map { satelliteId -> SlotCalculationSummaryDTO(satelliteId = satelliteId) } } + + @GetMapping("/satellites/{satelliteId}") + fun satellite(@PathVariable satelliteId: Long) = satelliteCatalogService.satellite(satelliteId) + + @GetMapping("/satellites/{satelliteId}/slot-calculation-summary") + fun satelliteSlotCalculationSummary(@PathVariable satelliteId: Long) = + runCatching { slotService.slotCalculationSummary(satelliteId) } + .getOrElse { SlotCalculationSummaryDTO(satelliteId = satelliteId) } + + @GetMapping("/satellites/pdcm") + fun satellitesPdcm() = satellitePdcmService.satelliteSummaries() + + @GetMapping("/satellites/pdcm/{satelliteId}") + fun satellitePdcm(@PathVariable satelliteId: Long) = satellitePdcmService.satelliteDetails(satelliteId) + + @PostMapping("/satellites") + fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) = + satelliteCatalogService.createSatellite(request) + + @PutMapping("/satellites/{satelliteId}") + fun updateSatellite( + @PathVariable satelliteId: Long, + @Valid @RequestBody request: SatelliteUpdateDTO + ) = satelliteCatalogService.updateSatellite(satelliteId, request) + + @DeleteMapping("/satellites/{satelliteId}") + fun deleteSatellite(@PathVariable satelliteId: Long): ResponseEntity { + satelliteCatalogService.deleteSatellite(satelliteId) + return ResponseEntity.noContent().build() + } + + @PostMapping("/satellites/{satelliteId}/observation-profile") + fun saveObservationProfile( + @PathVariable satelliteId: Long, + @RequestParam(defaultValue = "false") create: Boolean, + @Valid @RequestBody request: SatelliteObservationProfileDTO + ) = satelliteCatalogService.saveObservationProfile(satelliteId, request, create) + + @DeleteMapping("/satellites/{satelliteId}/observation-profile") + fun deleteObservationProfile(@PathVariable satelliteId: Long): ResponseEntity { + satelliteCatalogService.deleteObservationProfile(satelliteId) + return ResponseEntity.noContent().build() + } + + @PostMapping("/satellites/{satelliteId}/slot-profile") + fun saveSlotProfile( + @PathVariable satelliteId: Long, + @RequestParam(defaultValue = "false") create: Boolean, + @Valid @RequestBody request: SatelliteSlotProfileDTO + ) = satelliteCatalogService.saveSlotProfile(satelliteId, request, create) + + @PostMapping("/satellites/{satelliteId}/slot-calculation") + fun startSlotCalculation( + @PathVariable satelliteId: Long, + @Valid @RequestBody request: SatelliteSlotProfileDTO + ): ResponseEntity { + slotService.startSlotCalculation(satelliteId, request) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/satellites/{satelliteId}/slot-profile") + fun deleteSlotProfile(@PathVariable satelliteId: Long): ResponseEntity { + satelliteCatalogService.deleteSlotProfile(satelliteId) + return ResponseEntity.noContent().build() + } + + @GetMapping("/satellites/{satelliteId}/initial-conditions") + fun satelliteInitialConditions(@PathVariable satelliteId: Long): ResponseEntity { + val response = slotService.satelliteInitialConditions(satelliteId) + return if (response == null) { + ResponseEntity.noContent().build() + } else { + ResponseEntity.ok(response) + } + } + + @PostMapping("/satellites/{satelliteId}/initial-conditions") + fun saveSatelliteInitialConditions( + @PathVariable satelliteId: Long, + @RequestParam(defaultValue = "false") create: Boolean, + @Valid @RequestBody request: InitialConditionsDTO + ) = slotService.saveSatelliteInitialConditions(satelliteId, request, create) + + @GetMapping("/groups") + fun allGroups() = satelliteCatalogService.allGroups() + + @GetMapping("/groups/{groupId}") + fun group(@PathVariable groupId: Long) = satelliteCatalogService.group(groupId) + + @GetMapping("/group-states") + fun allGroupStates() = groupStateService.groupSummaries() + + @GetMapping("/group-states/{groupId}") + fun groupState( + @PathVariable groupId: Long, + @RequestParam(required = false) time: LocalDateTime? + ) = groupStateService.groupState(groupId, time) + + @PostMapping("/groups") + fun createGroup(@Valid @RequestBody request: SatelliteGroupCreateDTO) = + satelliteCatalogService.createGroup(request) + + @PutMapping("/groups/{groupId}") + fun updateGroup( + @PathVariable groupId: Long, + @Valid @RequestBody request: SatelliteGroupUpdateDTO + ) = satelliteCatalogService.updateGroup(groupId, request) + + @DeleteMapping("/groups/{groupId}") + fun deleteGroup(@PathVariable groupId: Long): ResponseEntity { + satelliteCatalogService.deleteGroup(groupId) + return ResponseEntity.noContent().build() + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/ComplexPlanController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/ComplexPlanController.kt new file mode 100644 index 0000000..4191fb9 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/ComplexPlanController.kt @@ -0,0 +1,44 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.stereotype.Controller +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +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.ResponseBody +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.slots_service.service.ComplexMissionService + +@Controller +class ComplexPlanPageController { + + @GetMapping("/com-plan") + fun complexPlanPage(): String = "complex-plan" +} + +@RestController +@RequestMapping("/api/com-plan/runs") +class ComplexPlanRunsRestController( + private val complexMissionService: ComplexMissionService +) { + + @GetMapping + @ResponseBody + fun runs() = complexMissionService.complexPlanRuns() + + @GetMapping("/{id}") + @ResponseBody + fun run(@PathVariable id: Long) = complexMissionService.complexPlanRun(id) + + @GetMapping("/{id}/modes") + @ResponseBody + fun runModes(@PathVariable id: Long) = complexMissionService.complexPlanRunModes(id) + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteRun(@PathVariable id: Long) { + complexMissionService.deleteComplexPlanRun(id) + } +} 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 new file mode 100644 index 0000000..dc8a956 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt @@ -0,0 +1,47 @@ +package space.nstart.pcp.slots_service.controller + +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +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 java.util.UUID + +@RestController +@RequestMapping("/api/current-plans") +class CurrentPlansController( + private val satelliteCatalogService: SatelliteCatalogService, + private val missionService: MissionService +) { + + @GetMapping("/satellites") + fun satellites() = satelliteCatalogService.allSatellites() + + @GetMapping("/satellites/{satelliteId}/missions") + fun missions( + @PathVariable satelliteId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "4") size: Int + ) = missionService.missionsBySatellite(satelliteId, page, size) + + @PostMapping("/missions") + fun createMission(@Valid @RequestBody body: MissionRequestDTO) = missionService.createMission(body) + + @GetMapping("/missions/{missionId}/modes") + fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId) + + @PostMapping("/missions/{missionId}/surveys/calculate") + fun calculateSurveys( + @PathVariable missionId: UUID, + @RequestParam(name = "comPlanSnapshotId", required = false) comPlanSnapshotId: Long? + ) = missionService.calculateSurveys(missionId, comPlanSnapshotId) + + @PostMapping("/missions/{missionId}/drops/calculate") + fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt new file mode 100644 index 0000000..e441de7 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt @@ -0,0 +1,67 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.ResponseBody +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO +import space.nstart.pcp.slots_service.service.DynamicPlanService +import java.util.UUID + +@Controller +class DynamicPlanPageController { + + @GetMapping("/dynamic-plan") + fun dynamicPlanPage(): String = "dynamic-plan" +} + +@RestController +@RequestMapping("/api/dynamic-plan") +class DynamicPlanRestController( + private val dynamicPlanService: DynamicPlanService +) { + + @PostMapping("/calc") + @ResponseBody + fun calculate(@RequestBody request: DynamicPlanCalculationRequestDTO) = + dynamicPlanService.calculate(request) + + @GetMapping("/runs") + @ResponseBody + fun getRuns( + @RequestParam(required = false) status: String?, + @RequestParam(defaultValue = "50") limit: Int, + @RequestParam(defaultValue = "0") offset: Int + ) = dynamicPlanService.getRuns(status, limit, offset) + + @GetMapping("/runs/{runId}") + @ResponseBody + fun getRun(@PathVariable runId: UUID) = + dynamicPlanService.getRun(runId) + + @GetMapping("/runs/{runId}/result") + @ResponseBody + fun getResult(@PathVariable runId: UUID) = + dynamicPlanService.getResult(runId) + + @GetMapping("/runs/{runId}/routes") + @ResponseBody + fun getRoutes( + @PathVariable runId: UUID, + @RequestParam(defaultValue = "1000") limit: Int, + @RequestParam(defaultValue = "0") offset: Int + ) = dynamicPlanService.getRoutes(runId, limit, offset) + + @GetMapping("/runs/{runId}/intervals") + @ResponseBody + fun getIntervals( + @PathVariable runId: UUID, + @RequestParam(defaultValue = "1000") limit: Int, + @RequestParam(defaultValue = "0") offset: Int + ) = dynamicPlanService.getIntervals(runId, limit, offset) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/MapController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/MapController.kt new file mode 100644 index 0000000..fd58c0f --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/MapController.kt @@ -0,0 +1,103 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO +import space.nstart.pcp.slots_service.service.EarthService +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.SlotService +import space.nstart.pcp.slots_service.service.StationService +import java.time.LocalDateTime + + +@Controller +class MapController { + + @Autowired + private lateinit var earthService: EarthService + @Autowired + private lateinit var complexMissionService: ComplexMissionService + @Autowired + private lateinit var stationService: StationService + @Autowired + private lateinit var slotService: SlotService + + + @GetMapping("/map") + fun showMap(model: Model): Mono { + model.addAttribute("requests", earthService.reqs().collectList().block()) + model.addAttribute("satellites", complexMissionService.satellites().collectList().block()) + model.addAttribute("stations", stationService.stations().collectList().block()) + return Mono.just("map") + } + + @PostMapping("/api/requests") + @ResponseBody + fun addRequest(@RequestBody request: RequestDTO): RequestDTO? { + return earthService.addRequest(request) + } + + @PostMapping("/api/slots/booking/request") + @ResponseBody + fun bookRequestSlots(@RequestBody request: BookingRequestDTO): List { + return slotService.bookReq(request) + } + + @DeleteMapping("/api/slots/booking/request") + @ResponseBody + fun cancelRequestBooking(@RequestParam requestId: String): Int { + return slotService.cancelReq(requestId) + } + + @GetMapping("/api/satellites/{satelliteId}/booked-slots") + @ResponseBody + fun satelliteBookedSlots( + @PathVariable satelliteId: Long, + @RequestParam timeStart: LocalDateTime, + @RequestParam timeStop: LocalDateTime + ): List { + return slotService.plan(satelliteId, timeStart, timeStop) + .collectList() + .block() + ?: emptyList() + } + + @GetMapping("/api/satellites/{satelliteId}/slots") + @ResponseBody + fun satelliteSlots( + @PathVariable satelliteId: Long, + @RequestParam timeStart: LocalDateTime, + @RequestParam timeStop: LocalDateTime + ): List = slotService.allSlotsByInterval(satelliteId, timeStart, timeStop) + + @PostMapping("/api/com-plan/process") + @ResponseBody + fun processComplexPlan(@RequestBody request: ComplexPlanProcessRequestDTO) = + complexMissionService.processComplexPlan(request) + +// @GetMapping("/map") +// fun showMap() : Mono{ +// val requests = earthService.reqs().toList() +// +// return Mono.just( +// Rendering +// .view("map") +// .modelAttribute("requests", requests) +// .build() +// ) +// } +} 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 new file mode 100644 index 0000000..44a2c0f --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/RvaController.kt @@ -0,0 +1,60 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.ResponseBody +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.SlotService +import space.nstart.pcp.slots_service.service.StationService + +@Controller +class RvaPageController( + private val complexMissionService: ComplexMissionService, + private val stationService: StationService +) { + + @GetMapping("/rva") + fun rvaPage(model: Model): String { + model.addAttribute("satellites", complexMissionService.satellites().collectList().block() ?: emptyList()) + model.addAttribute("stations", stationService.stations().collectList().block() ?: emptyList()) + return "rva" + } +} + +@RestController +@RequestMapping("/api/rva") +class RvaRestController( + private val slotService: SlotService +) { + + @PostMapping("/common/{satelliteId}/{duration}") + @ResponseBody + fun rvaCommon( + @PathVariable satelliteId: Long, + @PathVariable duration: Long, + @RequestBody stations: List + ) = slotService.rvaCommon(satelliteId, duration, stations) + + @PostMapping("/merge/{satelliteId}/{duration}") + @ResponseBody + fun rvaMerge( + @PathVariable satelliteId: Long, + @PathVariable duration: Long, + @RequestBody stations: List + ) = slotService.rvaMerge(satelliteId, duration, stations) + + @PostMapping("/merge-on-rev/{satelliteId}/{duration}") + @ResponseBody + fun rvaMergeOnRev( + @PathVariable satelliteId: Long, + @PathVariable duration: Long, + @RequestBody stations: List + ) = slotService.rvaMergeOnRev(satelliteId, duration, stations) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/StationCatalogController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/StationCatalogController.kt new file mode 100644 index 0000000..00bc822 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/StationCatalogController.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.slots_service.controller + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.StationDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.service.StationService + +@Controller +class StationCatalogPageController { + + @GetMapping("/stations") + fun stationsPage(): String = "stations" +} + +@RestController +@RequestMapping("/api/stations") +class StationCatalogRestController( + private val stationService: StationService +) { + + @GetMapping + fun allStations() = stationService.allStations() + + @GetMapping("/{stationId}") + fun station(@PathVariable stationId: String) = + stationService.station(stationId).block() + ?: throw CustomErrorException("Не удалось получить станцию $stationId: пустой ответ") + + @PostMapping + fun saveStation(@Valid @RequestBody request: StationDTO) = stationService.saveStation(request) + + @DeleteMapping("/{stationId}") + fun deleteStation(@PathVariable stationId: String): ResponseEntity { + stationService.deleteStation(stationId) + return ResponseEntity.noContent().build() + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLBillboard.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLBillboard.kt new file mode 100644 index 0000000..675e994 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLBillboard.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Картинка") +class CZMLBillboard ( + @Schema(title = "") + val image : String?, + @Schema(title = "") + val scale : Double?, + val show : Boolean = true){ + + constructor() : this (null, null) +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLClock.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLClock.kt new file mode 100644 index 0000000..6416d0a --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLClock.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor +import java.time.LocalDateTime + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Часы") +class CZMLClock( + val interval : String = "", + val currentTime : String = "", + val multiplayer : Int = 1, + var range : String = "LOOP_STOP", + var step : String = "SYSTEM_CLOCK_MULTIPLIER" +) { + constructor(tn : LocalDateTime, tk : LocalDateTime, cur : LocalDateTime) : this( + interval ="${ + tn.toString() + }/${ + tk + .toString() + }", + currentTime = cur.toString() + ) + +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLColor.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLColor.kt new file mode 100644 index 0000000..25c07ec --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLColor.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Узел CZML") +class CZMLColor( + var rgba : Iterable? = null +) { + constructor(r : Int, g : Int, b : Int, a : Int) : this (rgba = listOf(r,g,b,a)) +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLEllipse.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLEllipse.kt new file mode 100644 index 0000000..f9a60e6 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLEllipse.kt @@ -0,0 +1,29 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLEllipse( + val show : Boolean = true, + val semiMajorAxis : Double = 1.0, + val semiMinorAxis : Double = 1.0, + val extrudedHeight : Double = 0.0, + val height : Double = 0.0, + val fill : Boolean = false, + val outline : Boolean = true, + val outlineWidth : Double = 3.0, + val outlineColor : CZMLColor = CZMLColor(0,0,225,225) +) { + +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLEntity.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLEntity.kt new file mode 100644 index 0000000..c910d5a --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLEntity.kt @@ -0,0 +1,33 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLEntity ( + val id : String, + val name : String, + val version : String? = null, + val description : String? = null, + val billboard : CZMLBillboard? = null, + val position : CZMLPosition? = null, + val ellipse : CZMLEllipse? = null, + val polyline : CZMLPolyLine? = null, + val point : CZMLPoint? = null, + val clock : CZMLClock? = null, + val polygon: CZMLPolygon? = null, + val path : CZMLPath? = null, + val model: CZMLModel? = null + ){ + +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLInterval.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLInterval.kt new file mode 100644 index 0000000..9fc6af7 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLInterval.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLInterval( + var interval : String = "", + var epoch : String = "", + var number : Iterable = mutableListOf() +) { +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLLabel.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLLabel.kt new file mode 100644 index 0000000..5ba28ba --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLLabel.kt @@ -0,0 +1,28 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLLabel( + val text : String, + val show: Boolean = true, + val style : String = "FILL", + val horizontalOrigin : String = "LEFT", + val verticalOrigin : String = "CENTER", + val font : String = "11pt Lucida Console", + val scale : Int = 1, + val fillColor : CZMLColor = CZMLColor(0,0,0,0), + val outLineColor: CZMLSolidColor? = null +) { +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLMaterial.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLMaterial.kt new file mode 100644 index 0000000..67e9f6f --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLMaterial.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLMaterial( + val solidColor: CZMLSolidColor? = null, +) { + constructor(r : Int, g : Int, b : Int, a : Int) : this(CZMLSolidColor(r, g, b, a)) + +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLModel.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLModel.kt new file mode 100644 index 0000000..927763d --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLModel.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLModel( + val gltf : String? = null, + val uri : String? = null, + val minimumPixelSize: Int = 64, + val maximumScale: Int = 10000, + val scale: Int = 1000, + val show: Boolean = true, + val shadows : String = "DISABLED" +) { +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPath.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPath.kt new file mode 100644 index 0000000..92c24fe --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPath.kt @@ -0,0 +1,107 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor +import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO +import java.time.LocalDateTime +import java.time.ZoneOffset + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLPath( + var show: Boolean = true, + val width: Int = 1, + val resolution: Int = 120, + val material: CZMLMaterial = CZMLMaterial(255, 0, 0, 0), + var leadTime: Iterable = listOf(), + var trailTime: Iterable = listOf() +) { + + private fun fromDateTime(t: LocalDateTime) = t.toEpochSecond(ZoneOffset.UTC) + t.nano / 1e9 + + constructor( + vuz: Iterable, epoch: LocalDateTime, epochStop: LocalDateTime, width: Int, resolution: Int, + material: CZMLMaterial + ) : this(width = width, resolution = resolution, material = material) { + + + var t = epoch.toEpochSecond(ZoneOffset.UTC).toDouble() + var lt = mutableListOf() + var tt = mutableListOf() + + for (v in vuz) { + + var dt = fromDateTime(v.time) - t + if (dt > 1) { + lt.add( + CZMLInterval( + "${ + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC) + }/${ + LocalDateTime.ofEpochSecond(fromDateTime(v.time).toLong(), (fromDateTime(v.time) % 1 * 1e9).toInt(), ZoneOffset.UTC) + .toString() + }", + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(), + listOf(0.0, dt, dt, 0.0) + ) + ) + + tt.add( + CZMLInterval( + "${ + LocalDateTime.ofEpochSecond(t.toLong(), ((t) % 1 * 1e9).toInt(), ZoneOffset.UTC) + }/${ + LocalDateTime.ofEpochSecond((fromDateTime(v.time)).toLong(), (fromDateTime(v.time) % 1 * 1e9).toInt(), ZoneOffset.UTC) + .toString() + }", + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(), + listOf(0.0, 0.0, dt, dt) + ) + ) + } + t = fromDateTime(v.time) + } + + var tk = epochStop.toEpochSecond(ZoneOffset.UTC).toDouble() + var dt = tk - t + if (dt > 1) { + lt.add( + CZMLInterval( + "${ + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC) + }/${ + LocalDateTime.ofEpochSecond((tk).toLong(), (tk % 1 * 1e9).toInt(), ZoneOffset.UTC) + .toString() + }", + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(), + listOf(0.0, dt, dt, 0.0) + ) + ) + + tt.add( + CZMLInterval( + "${ + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC) + }/${ + LocalDateTime.ofEpochSecond((tk).toLong(), (tk % 1 * 1e9).toInt(), ZoneOffset.UTC) + .toString() + }", + LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(), + listOf(0.0, 0.0, dt, dt) + ) + ) + } + + leadTime = lt + trailTime = tt + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPoint.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPoint.kt new file mode 100644 index 0000000..9b0773c --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPoint.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLPoint( + val show : Boolean = true, + val pixelSize : Double = 2.0, + val color : CZMLColor = CZMLColor(255,0,0,255) +){ + +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPolyLine.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPolyLine.kt new file mode 100644 index 0000000..ed83f9c --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPolyLine.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLPolyLine( + val show : Boolean = true, + val width : Double = 1.0, + val followSurface : Boolean = false, + val material : CZMLMaterial? = null, + val positions : CZMLPosition +) { +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPolygon.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPolygon.kt new file mode 100644 index 0000000..86ea711 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPolygon.kt @@ -0,0 +1,30 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLPolygon( + val positions : CZMLPosition, + val show : Boolean = true, + val perPositionHeight : Boolean = false, + val stRotation : Double = 0.0, + val extrudedHeight : Double = 0.0, + val height : Double = 0.0, + val fill : Boolean = false, + val outline : Boolean = true, + val outlineWidth : Double = 3.0, + val outlineColor : CZMLColor = CZMLColor(0,0,225,225), + val material : CZMLMaterial? = null +) { +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPosition.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPosition.kt new file mode 100644 index 0000000..dcb704b --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLPosition.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Положение") +class CZMLPosition( + val referenceFrame : String = "FIXED", + val cartesian : Iterable? = null, + val cartographicRadians : Iterable? = null, + val cartographicDegrees : Iterable? = null, + val interpolationAlgorithm :String? = null, + val interpolationDegree : Double? = null, + val epoch : String? = null +){ +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLSolidColor.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLSolidColor.kt new file mode 100644 index 0000000..ade1e8b --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/czml/CZMLSolidColor.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.slots_service.czml + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema +import lombok.AllArgsConstructor +import lombok.Data +import lombok.NoArgsConstructor + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Узел CZML") +@JsonInclude(JsonInclude.Include.NON_NULL) +class CZMLSolidColor( + val color : CZMLColor +) { + constructor(r : Int, g : Int, b : Int, a : Int) :this (color = CZMLColor(r,g,b,a)) +} \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/currentplans/CurrentPlansDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/currentplans/CurrentPlansDTO.kt new file mode 100644 index 0000000..e9baef0 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/currentplans/CurrentPlansDTO.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.slots_service.dto.currentplans + +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO + +/** + * Страница миссий выбранного спутника для UI "Текущие планы". + * Пагинация выполняется в ui-service поверх существующего API mission-service. + */ +data class CurrentPlansMissionPageDTO( + val satelliteId: Long, + val page: Int, + val size: Int, + val totalElements: Int, + val totalPages: Int, + val items: List +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanCalculationModeDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanCalculationModeDTO.kt new file mode 100644 index 0000000..f77dff8 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanCalculationModeDTO.kt @@ -0,0 +1,6 @@ +package space.nstart.pcp.slots_service.dto.dynamicplan + +enum class DynamicPlanCalculationModeDTO { + FULL, + GREEDY +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanCalculationRequestDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanCalculationRequestDTO.kt new file mode 100644 index 0000000..1e2d142 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanCalculationRequestDTO.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.slots_service.dto.dynamicplan + +import java.time.LocalDateTime +import java.util.UUID + +data class DynamicPlanCalculationRequestDTO( + val requestId: UUID, + val satelliteIds: List, + val calculationStart: LocalDateTime, + val calculationEnd: LocalDateTime, + val calculationMode: DynamicPlanCalculationModeDTO = DynamicPlanCalculationModeDTO.FULL, + val revolutionMode: DynamicPlanRevolutionModeDTO = DynamicPlanRevolutionModeDTO.BOTH, + val filterCoveredRoutes: Boolean = false +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanRevolutionModeDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanRevolutionModeDTO.kt new file mode 100644 index 0000000..df21eff --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/dynamicplan/DynamicPlanRevolutionModeDTO.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp.slots_service.dto.dynamicplan + +enum class DynamicPlanRevolutionModeDTO { + BOTH, + ASC, + DESC +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt new file mode 100644 index 0000000..ee50115 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt @@ -0,0 +1,45 @@ +package space.nstart.pcp.slots_service.dto.groupstate + +import java.time.LocalDateTime + +data class GroupStateAvailabilityDTO( + val timeStart: LocalDateTime, + val timeStop: LocalDateTime +) + +data class GroupStateSummaryDTO( + val groupId: Long, + val groupName: String, + val satelliteCount: Int, + val availableInterval: GroupStateAvailabilityDTO? = null +) + +data class GroupStateSatelliteDTO( + val satelliteId: Long, + val noradId: Long?, + val code: String, + val name: String, + val omegabDeg: Double, + val uDeg: Double +) + +data class GroupStateMatrixCellDTO( + val rowSatelliteId: Long, + val columnSatelliteId: Long, + val deltaOmegabDeg: Double, + val deltaUDeg: Double +) + +data class GroupStateMatrixRowDTO( + val satelliteId: Long, + val cells: List +) + +data class GroupStateDetailsDTO( + val groupId: Long, + val groupName: String, + val availableInterval: GroupStateAvailabilityDTO? = null, + val calculationTime: LocalDateTime? = null, + val satellites: List = emptyList(), + val matrix: List = emptyList() +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt new file mode 100644 index 0000000..41870ce --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.slots_service.dto.pdcm + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO +import java.time.LocalDateTime + +data class SatellitePdcmAvailabilityDTO( + val timeStart: LocalDateTime, + val timeStop: LocalDateTime +) + +data class SatellitePdcmSummaryDTO( + val satelliteId: Long, + val code: String, + val name: String, + val activeInterval: Boolean, + val availableInterval: SatellitePdcmAvailabilityDTO? +) + +data class SatellitePdcmDetailsDTO( + val satelliteId: Long, + val code: String, + val name: String, + val activeInterval: Boolean, + val availableInterval: SatellitePdcmAvailabilityDTO?, + val ascNodes: List = emptyList() +) 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 new file mode 100644 index 0000000..6f53aa9 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/rest/RequestRestController.kt @@ -0,0 +1,119 @@ +package space.nstart.pcp.slots_service.rest + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +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.ballistics.RevolutionSign +import space.nstart.pcp.slots_service.service.CZMLService +import space.nstart.pcp.slots_service.service.CoverageSchemeService +import java.time.LocalDateTime + + +@RestController +@RequestMapping("/requests") +class RequestRestController { + @Autowired + private lateinit var czmlService: CZMLService + + @Autowired + private lateinit var coverageSchemeService: CoverageSchemeService + + @PostMapping() + fun test ( + @RequestParam importance: Double, + @RequestParam(required = false) countLat: Int?, + @RequestParam(required = false) countLong: Int?, + ) = + czmlService.drawHeatMap(importance, countLat, countLong) + //czmlService.showWithCells() + + @PostMapping("/req-with-cells/{id}") + fun reqWithCells(@PathVariable("id") id : String) = + czmlService.showWithCells(id) + + @PostMapping("/plan/{satellite_id}") + fun plan(@PathVariable("satellite_id") id : Long, + @RequestParam tn : LocalDateTime, + @RequestParam tk : LocalDateTime, + @RequestParam(required = false) runId: Long?) = + czmlService.showSatPlan(id, tn, tk, runId) + + @GetMapping("/plan/{satellite_id}") + fun planGet( + @PathVariable("satellite_id") id: Long, + @RequestParam tn: LocalDateTime, + @RequestParam tk: LocalDateTime, + @RequestParam(required = false) runId: Long? + ) = czmlService.showSatPlan(id, tn, tk, runId) + + + @PostMapping("/sat/{satellite_id}") + fun sat( + @PathVariable("satellite_id") id : Long, + @RequestParam view : Boolean, + @RequestParam orbit : Boolean, + @RequestParam sw : Boolean, + @RequestParam fl : Boolean, + @RequestParam(required = false) tn : LocalDateTime?, + @RequestParam(required = false) tk : LocalDateTime?) = + + czmlService.satFLD(id, view, orbit, sw, fl, tn, tk) + + + @PostMapping("/station/{station_id}") + fun sat(@PathVariable("station_id") id : String, @RequestParam view : Boolean) = + czmlService.station(id, view) + + + + @PostMapping("req-with-covs/{request_id}") + fun covs( + @PathVariable("request_id") id : String, + @RequestParam tn : LocalDateTime, + @RequestParam tk : LocalDateTime, + @RequestParam sign : RevolutionSign?, + @RequestParam cov : Boolean?, + @RequestParam(required = false) sun : Double?, + @RequestParam(name = "satellites", required = false) satellites : List?, + @RequestParam(name = "satellites[]", required = false) satellitesArray : List? + ) = czmlService.covs( + id, + tn, + tk, + sign, + cov, + sun, + satellites?.takeIf { it.isNotEmpty() } ?: satellitesArray?.takeIf { it.isNotEmpty() } + ) + + @PostMapping("req-with-coverage-scheme/{request_id}") + fun coverageScheme( + @PathVariable("request_id") id: String, + @RequestParam tn: LocalDateTime, + @RequestParam tk: LocalDateTime, + @RequestParam(name = "satellites", required = false) satellites: List?, + @RequestParam(name = "satellites[]", required = false) satellitesArray: List?, + @RequestParam(required = false) rollStepDegrees: Double?, + @RequestParam(required = false) minimumTechnologyOverlap: Double?, + @RequestParam(required = false) strictContinuousCoverage: Boolean?, + @RequestParam(required = false) allowPartialCoverage: Boolean?, + @RequestParam(required = false) orientationToleranceDegrees: Double?, + @RequestParam(required = false) includeDebugInfo: Boolean? + ) = coverageSchemeService.requestCoverageScheme( + id, + tn, + tk, + satellites?.takeIf { it.isNotEmpty() } ?: satellitesArray?.takeIf { it.isNotEmpty() } ?: emptyList(), + rollStepDegrees, + minimumTechnologyOverlap, + strictContinuousCoverage, + allowPartialCoverage, + orientationToleranceDegrees, + includeDebugInfo + ) + +} 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 new file mode 100644 index 0000000..ac49885 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt @@ -0,0 +1,81 @@ +package space.nstart.pcp.slots_service.service + +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 org.springframework.web.util.UriComponentsBuilder +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.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import java.net.URI +import java.time.LocalDateTime + + +@Service +class BallisticsService (webClientBuilderProvider: ObjectProvider){ + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.ballistics-service:ballistics-service}") + val url = "" + + + fun ascNodes(id : Long, timeStart: LocalDateTime? = null, timeStop: LocalDateTime? = null) = + webClientBuilder.build() + .get() + .uri(withTimeInterval("/api/satellites/$id/asc-node", timeStart, timeStop)) + .retrieve() + .bodyToFlux(AscNodeDTO::class.java) + + + fun flightLine(id : Long, timeStart: LocalDateTime? = null, timeStop: LocalDateTime? = null) = + webClientBuilder.build() + .get() + .uri(withTimeInterval("/api/satellites/$id/flight-line", timeStart, timeStop)) + .retrieve() + .bodyToFlux(FlightLineDTO::class.java) + + + fun points(id : Long, timeStart: LocalDateTime? = null, timeStop: LocalDateTime? = null) = + webClientBuilder.build() + .get() + .uri(withTimeInterval("/api/satellites/$id/orbit", timeStart, timeStop)) + .retrieve() + .bodyToFlux(OrbPointDTO::class.java) + + fun orbitAvailability(): List = + webClientBuilder.build() + .get() + .uri("$url/api/satellites/orbit/availability") + .retrieve() + .bodyToFlux(SatelliteOrbitAvailabilityDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun exactTimePoint(id: Long, time: LocalDateTime): OrbPointDTO = + webClientBuilder.build() + .post() + .uri("$url/api/satellites/$id/extract-time") + .bodyValue(ExactTimePositionRequestDTO(time = time)) + .retrieve() + .bodyToFlux(OrbPointDTO::class.java) + .collectList() + .block() + ?.firstOrNull() + ?: throw CustomErrorException("Не удалось получить положение спутника $id на время $time") + + private fun withTimeInterval( + path: String, + timeStart: LocalDateTime?, + timeStop: LocalDateTime? + ): URI { + val builder = UriComponentsBuilder.fromUriString("$url$path") + timeStart?.let { builder.queryParam("time_start", it) } + timeStop?.let { builder.queryParam("time_stop", it) } + return builder.build().toUri() + } +} 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 new file mode 100644 index 0000000..b884714 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt @@ -0,0 +1,565 @@ +package space.nstart.pcp.slots_service.service + +import org.locationtech.jts.geom.Polygon +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.RequestParam +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +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.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO +import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.czml.* +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.sin + +@Service +class CZMLService { + + private data class SatelliteCzmlStyle( + val outlineColor: CZMLColor, + val material: CZMLMaterial + ) + + val logger : Logger = LoggerFactory.getLogger(this.javaClass) + + @Autowired + private lateinit var earthService: EarthService + @Autowired + private lateinit var complexMissionService: ComplexMissionService + @Autowired + private lateinit var ballisticsService: BallisticsService + @Autowired + private lateinit var slotService: SlotService + + private val docNameHeatMap = "heatMap" + private val docNameReq = "req_" + private val entNameReq = "request_" + private val entityNip = "cell_" + private val docPlan = "plan_" + private val entityMar = "survey_" + private val docStationName = "station_" + private val entityStationName = "station_" +private val docReqCovName ="req_cov_" + + @Autowired + private lateinit var stationService: StationService + + private fun ellipseAx(hOrb: Double, gamma: Double): Double { + val rz = 6378100.0 + val gam = asin(rz / (rz + hOrb) * sin(PI / 2.0 + gamma)) + val a = PI - gam - PI / 2 - gamma + return rz * a + } + + + fun showSatPlan(id : Long , tn : LocalDateTime, + tk : LocalDateTime, + runId: Long? = null) : Iterable{ + val docId = docPlan + id + val wktParser = WKTParser() + val r: MutableList = mutableListOf(CZMLEntity("document", docId , "1.1")) + + var ind = 0 + val sat = complexMissionService.satellite(id).block() + val mars = complexMissionService.plan(id, tn, tk, runId).collectList().block() + + if (sat != null) { + + println("${sat.red} ${sat.green} ${sat.blue}") + + val ents = mars?.mapNotNull { mar -> + + val poly = wktParser.parseWKT(mar.contourWkt?:"") + if (poly is Polygon) { + + val pos = mutableListOf() + poly.coordinates.forEach { coordinate -> + pos.add(coordinate.x) + pos.add(coordinate.y) + pos.add(0.0) + } + + + CZMLEntity( + entityMar + "${id}_${++ind}", "Маршрут КА $id №$ind", null, + description = "Время начала : ${mar.startTime}
" + + "Время конца : ${mar.startTime.plusSeconds(mar.duration.toLong())}
" + + "Виток : ${mar.revolution}
" + + "Крен : ${mar.roll}
" + + "Широта : ${mar.lat}"+ + "Долгота : ${mar.longitude}"+ + "Слотов : ${mar.bookedSlotIds.size}" + , + polygon = CZMLPolygon( + positions = CZMLPosition( + cartographicDegrees = pos + ), + outlineWidth = 2.0, + fill = true, + outlineColor = CZMLColor(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 255), + material = CZMLMaterial(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 150) + ) + ) + } else { + logger.warn("Контур маршрута не ПОЛИГОН! ${mar.contourWkt}") + null + } + } + if (ents!=null) + r.addAll(ents) + + +// val slotEnts = mutableListOf() +// +// slotSurvs?.forEach { mar -> +// +// if (mar is SurveyModeResponseDTO) { +// +// val poly = wktParser.parseWKT(mar.contourWkt ?: "") as Polygon +// val pos = mutableListOf() +// poly.coordinates.forEach { coordinate -> +// pos.add(coordinate.x) +// pos.add(coordinate.y) +// pos.add(0.0) +// } +// +// +// slotEnts.add( +// CZMLEntity( +// entityMar + "${id}_${++ind}", "Маршрут бронирования КА $id №$ind", null, +// description = "Время начала : ${mar.timeStart}
" + +// "Время конца : ${mar.timeStart?.plusSeconds(mar.duration.toLong())}
" + +// "Виток : ${mar.revolution}
" + +// "Крен : ${mar.roll}
" + +// "Слотов : ${mar.bookedSlotIds.size}", +// polygon = CZMLPolygon( +// positions = CZMLPosition( +// cartographicDegrees = pos +// ), +// outlineWidth = 2.0, +// fill = true, +// outlineColor = CZMLColor(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 255), +// material = CZMLMaterial(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 150) +// +// ) +// +// ) +// ) +// } +// } +// r.addAll(slotEnts) + } + return r + } + + + + fun showWithCells(idReq : String) : Iterable{ + val id = idReq//"b9bad368-be24-410f-afa6-c140bacdbd8f" + val req = earthService.reqcells(id)?:return listOf() + val wktParser = WKTParser() + val r: MutableList = mutableListOf(CZMLEntity("document", docNameReq+id , "1.1")) +// r.addAll(req.cells.map { cell -> +// +// val poly = wktParser.parseWKT(cell.contour) as Polygon +// +// +// val pos = mutableListOf() +// poly.coordinates.forEach { coordinate -> +// pos.add(coordinate.x) +// pos.add(coordinate.y) +// pos.add(0.0) +// } +// +// val alfa = ((cell.importance / 1.2) * 200).toInt() +// println( alfa) +// CZMLEntity( +// entityNip + "${cell.id}", cell.id.toString(), null, +// +// polygon = CZMLPolygon( +// positions = CZMLPosition( +// cartographicDegrees = pos +// ), +// outlineWidth = 2.0, +// fill = true, +// outlineColor = CZMLColor(255,0,0,255), +// material = CZMLMaterial(255,0,0,alfa) +// +// ) +// +// ) +// } +// ) + val poly = wktParser.parseWKT(req.request.contour) as Polygon + + + val pos = mutableListOf() + poly.coordinates.forEach { coordinate -> + pos.add(coordinate.x) + pos.add(coordinate.y) + pos.add(0.0) + } + + r.add( + CZMLEntity( + entNameReq + "${req.request.requestId}", req.request.name, null, + + polygon = CZMLPolygon( + positions = CZMLPosition( + cartographicDegrees = pos + ), + outlineWidth = 2.0, + fill = true, + outlineColor = CZMLColor(191, 0, 255,255), + material = CZMLMaterial(191, 0, 255,150) + + ), + description = "важность : ${req.request.importance}
id : ${req.request.requestId}" + + + )) + + return r + + } + + + + fun drawHeatMap(importance: Double, countLat: Int? = null, countLong: Int? = null): Iterable { + + val cells = earthService.cells(0.0, countLat, countLong).toList() + val wktParser = WKTParser() + val r: MutableList = mutableListOf(CZMLEntity("document", docNameHeatMap, "1.1")) + + + if (!cells.any()) { + return r + } + + val importanceMax = cells.maxOf { it.importance } + if (importanceMax <= 0.0) { + return r + } + + r.addAll(cells.filter { it.importance / importanceMax >= importance }.map { cell -> + + val poly = wktParser.parseWKT(cell.contour) as Polygon + + + val pos = mutableListOf() + poly.coordinates.forEach { coordinate -> + pos.add(coordinate.x) + pos.add(coordinate.y) + pos.add(0.0) + } + + val importance = cell.importance / importanceMax + val alfa = 180 + + CZMLEntity( + entityNip + "${cell.id}", cell.id.toString(), null, + + description = "важность : $importance", + polygon = CZMLPolygon( + positions = CZMLPosition( + cartographicDegrees = pos + ), + outlineWidth = 2.0, + fill = true, + outlineColor = CZMLColor(255, 0, 0, 255), + material = CZMLMaterial(255, (255 * (1 - importance)).toInt(), 0, alfa) + + ) + + ) + } + ) + + return r + } + + class CovsDTO( + val cov : List = listOf(), + val slots : List = listOf() + ) + + private fun SatelliteInfoDTO.toSlotStyle() = SatelliteCzmlStyle( + outlineColor = CZMLColor(red.toInt(), green.toInt(), blue.toInt(), 255), + material = CZMLMaterial(red.toInt(), green.toInt(), blue.toInt(), 150) + ) + + private fun defaultSlotMaterial(slotStatus: SlotStatus) = + when (slotStatus) { + SlotStatus.BOOKED -> CZMLMaterial(0, (255 * (1 - 0.5)).toInt(), 0, 200) + SlotStatus.BUSY -> CZMLMaterial(125, 125, 125, 200) + else -> CZMLMaterial(255, (255 * (1 - 0.5)).toInt(), 0, 150) + } + + fun covs( + id : String, + tn : LocalDateTime, + tk : LocalDateTime, + sign : RevolutionSign?, + cover : Boolean?, + sun : Double?, + satellites : List? + ): CovsDTO { + + println("$id $tn $tk $sign $cover $sun $satellites") +// val req = earthService.reqcells(id)?:return listOf() + val r: MutableList = mutableListOf(CZMLEntity("document", docReqCovName + id, "1.1")) + val wktParser = WKTParser() + try { + val slots = slotService.slots(id, tn, tk, sign, cover, satellites, sun).collectList().block()?.toList() ?: return CovsDTO() + val satelliteStyles = slots + .map { it.satelliteId } + .distinct() + .associateWith { satelliteId -> + complexMissionService.satellite(satelliteId).block()?.toSlotStyle() + } + + r.addAll(slots.map { slot -> + val poly = wktParser.parseWKT(slot.contour) as Polygon + val satelliteStyle = satelliteStyles[slot.satelliteId] + + + val pos = mutableListOf() + poly.coordinates.forEach { coordinate -> + pos.add(coordinate.x) + pos.add(coordinate.y) + pos.add(0.0) + } + CZMLEntity( + "slot_${slot.satelliteId}_${slot.cycle}_${slot.slotNumber}", + "Слот", + "1.1", + description = "КА №${slot.satelliteId}
" + + "Виток ${slot.revolution} ${slot.revolutionSign}
" + + "ДМВ ${slot.tn}
" + + "Длительность ${abs(Duration.between(slot.tn, slot.tk).seconds)}
" + + "Крен ${slot.roll}
" + + "№слота ${slot.slotNumber}
" + + "Цикл ${slot.cycle}", + + polygon = CZMLPolygon( + positions = CZMLPosition( + cartographicDegrees = pos + ), + outlineWidth = 2.0, + fill = true, + outlineColor = satelliteStyle?.outlineColor ?: CZMLColor(255, 0, 0, 255), + material = satelliteStyle?.material ?: defaultSlotMaterial(slot.state) + ), + polyline = CZMLPolyLine( + width = 2.0, + material = satelliteStyle?.material ?: CZMLMaterial(255, 0, 0, 255), + positions = CZMLPosition( + cartographicDegrees = if (pos.size >= 3) pos + pos.take(3) else pos + ) + ) + ) + }) + return CovsDTO(r, slots) + } + catch (ex : CustomErrorException){ + logger.warn(ex.message) + throw ex + } + } + + + fun station(id : String, view : Boolean) : Iterable{ + val req = stationService.station(id).block()?:return listOf() + val radius = ellipseAx( (500.0 * 1000.0), req.elevationMin * PI / 180) + + val r: MutableList = mutableListOf(CZMLEntity("document", docStationName+id , "1.1")) + r.add( + CZMLEntity( + entityStationName + "${req.id}", req.name, null, + billboard = CZMLBillboard( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAScwAAEnMBjCK5BwAAABl0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMC4xOdTWsmQAAAFuSURBVEhLlZRRlsUgCEO79C6tO3O4SBSp7bzmnDSCkDw/Zo7W2mEw8UM7z3Mhvbj32a8Eph2cMb2u66YfQgSvlwAZvYXEvMzEgeTnd6CXAWoMn0LQ2HED7YgB3VlrNsevfDPPIaxqvdfDy80h8E4GPUzEXQjUblWDm0PgHcASoPeFfed0NQxzCGjeQD+/or5AM5MesJjD/nGZL8gGb+ZSelG7X2b/xDCQAaCvAFF9IGP0KaR/0kJV7na/nJp+vlfNmDgPsSjUECDFSKb0ZJ5m3RPOQwyCne7MUfq5TrNrgBdhklFDFJDNsnkN2QYw8KQ1RDV3Us0ZakB8DNm0KjMyRuk9hSwBGOD/SwjIQfSq1oCxwF8lCrKpmGvtYCrSHyE2O8ylnfuQJ51780Xet/sRoH9YGtQZZLP/qH2n7aSAlb3fXwJZBm9aWQLuLzBoxloz6FcuATs1aGaQO3FnCsdMLHmxU4NmtnSTDft9O/4AurwQU2nT95gAAAAASUVORK5CYII=", + 1.0, + show = view + ), + position = CZMLPosition( + cartographicDegrees = listOf(req.position.long, req.position.lat, req.position.height) + ), + ellipse = CZMLEllipse( + semiMajorAxis = radius, + semiMinorAxis = radius, + outlineColor = CZMLColor(250, 0, 0, 250), + extrudedHeight = 1000.0, + height = 1000.0 + ), + description = "Станция ${req.name}", + model = CZMLModel( + gltf = "/model/station.glb", + show = !view + ) + )) + return r + } + + + + fun fromDateTime(t: LocalDateTime) = t.toEpochSecond(ZoneOffset.UTC) + t.nano / 1e9 + + fun satFLD( + id : Long, + view : Boolean, + showOrbit : Boolean, + showSw : Boolean, + showFl : Boolean, + timeStart: LocalDateTime? = null, + timeStop: LocalDateTime? = null + ): Iterable { + + val tstart = timeStart ?: LocalDateTime.now() + val tstop = timeStop ?: tstart.plusDays(1) + var vuz = ballisticsService.ascNodes(id, tstart, tstop).collectList().block() + vuz = vuz?.filter { it.time >= tstart && it.time <= tstop }?.sortedBy { it.time } + val fl = ballisticsService.flightLine(id, tstart, tstop).collectList().block() + ?.filter { it.time >= tstart && it.time <= tstop } + ?.sortedBy { it.time } + val orbP = ballisticsService.points(id, tstart, tstop).collectList().block() + ?.filter { it.time >= tstart && it.time <= tstop } + ?.sortedBy { it.time } + val tn = fromDateTime(tstart) + + val sat = complexMissionService.satellite(id).block() + val doc = mutableListOf() + + if (sat != null && fl != null && vuz != null && orbP != null) { + + + val flr = mutableListOf() + val fll = mutableListOf() + val sw = mutableListOf() + val orb = mutableListOf() + for (op in fl) { + fll.addAll(listOf(fromDateTime(op.time) - tn, op.longLeft, op.latLeft, 50.0)) + flr.addAll(listOf(fromDateTime(op.time) - tn, op.longRight, op.latRight, 50.0)) + sw.addAll(listOf(fromDateTime(op.time) - tn, op.long, op.lat, 50.0)) + } + for (op in orbP) + orb.addAll(listOf(fromDateTime(op.time) - tn, op.x, op.y, op.z)) + + + val path = CZMLPath( + vuz, + tstart, + tstop, + 1, + 120, + CZMLMaterial( + sat.red.toInt(), + sat.green.toInt(), + sat.blue.toInt(), + 250 + ) + ) + + + val sats = listOf( +// "sat", + "radarSat", + "CloudSat") + doc.addAll( + mutableListOf( + CZMLEntity( + id = "document", name = "sat_$id", version = "1.1", + clock = CZMLClock(tstart, tstop, tstart) + ), + CZMLEntity( + id = "swath_r_ka_$id", name = sat.name, + + position = CZMLPosition( + cartographicDegrees = flr, + interpolationAlgorithm = "LAGRANGE", + interpolationDegree = 1.0, + epoch = tstart.toString() + ), + path = path.apply { show = showSw } + + ), + CZMLEntity( + id = "swath_l_ka_$id", name = sat.name, + position = CZMLPosition( + cartographicDegrees = fll, + interpolationAlgorithm = "LAGRANGE", + interpolationDegree = 1.0, + epoch = tstart.toString() + ), + path = path.apply { show = showSw } + ), + + CZMLEntity( + id = "flight_line_ka_$id", name = sat.name, + point = CZMLPoint( + pixelSize = 5.0, color = CZMLColor( + sat.red.toInt(), + sat.green.toInt(), + sat.blue.toInt(), + 250 + ), + show = showFl + + ), + position = CZMLPosition( + cartographicDegrees = sw, + interpolationAlgorithm = "LAGRANGE", + interpolationDegree = 1.0, + epoch = tstart.toString() + ), + path = path.apply { show = showFl } + ), + + CZMLEntity( + id = "orbit_$id", name = sat.name, + point = CZMLPoint( + pixelSize = 8.0, + color = CZMLColor( + sat.red.toInt(), + sat.green.toInt(), + sat.blue.toInt(), + 250 + ), + show = view + ), + model = CZMLModel( + gltf = "/model/${sats.shuffled().first()}.glb", + show = !view + ), +// billboard = CZMLBillboard( +// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAScwAAEnMBjCK5BwAAABl0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMC4xOdTWsmQAAAFuSURBVEhLlZRRlsUgCEO79C6tO3O4SBSp7bzmnDSCkDw/Zo7W2mEw8UM7z3Mhvbj32a8Eph2cMb2u66YfQgSvlwAZvYXEvMzEgeTnd6CXAWoMn0LQ2HED7YgB3VlrNsevfDPPIaxqvdfDy80h8E4GPUzEXQjUblWDm0PgHcASoPeFfed0NQxzCGjeQD+/or5AM5MesJjD/nGZL8gGb+ZSelG7X2b/xDCQAaCvAFF9IGP0KaR/0kJV7na/nJp+vlfNmDgPsSjUECDFSKb0ZJ5m3RPOQwyCne7MUfq5TrNrgBdhklFDFJDNsnkN2QYw8KQ1RDV3Us0ZakB8DNm0KjMyRuk9hSwBGOD/SwjIQfSq1oCxwF8lCrKpmGvtYCrSHyE2O8ylnfuQJ51780Xet/sRoH9YGtQZZLP/qH2n7aSAlb3fXwJZBm9aWQLuLzBoxloz6FcuATs1aGaQO3FnCsdMLHmxU4NmtnSTDft9O/4AurwQU2nT95gAAAAASUVORK5CYII=", +// 1.0 +// ), + position = CZMLPosition( + cartesian = orb, + interpolationAlgorithm = "LAGRANGE", + interpolationDegree = 1.0, + epoch = tstart.toString() + ), + path = path.apply { show = showOrbit } + ) + + + ) + ) + + } + return doc + + } + +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/ComplexMissionService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/ComplexMissionService.kt new file mode 100644 index 0000000..02fe1bc --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/ComplexMissionService.kt @@ -0,0 +1,167 @@ +package space.nstart.pcp.slots_service.service + +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.Flux +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime + + +@Service +class ComplexMissionService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.complex-mission-service:pcp-complex-mission-service}") + val complexMissionUrl = "" + + @Value("\${settings.satellite-catalog-service:\${settings.complex-mission-service:pcp-satellite-catalog-service}}") + val satelliteCatalogUrl = "" + + fun satellites() = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogUrl/api/satellites") + .retrieve() + .bodyToFlux(SatelliteSummaryDTO::class.java) + .map { it.toLegacyInfo() } + + fun satellite(sat : Long) = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogUrl/api/satellites/$sat") + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .map { it.toLegacyInfo() } + + fun plan(sat : Long, tn : LocalDateTime, tk : LocalDateTime, runId: Long? = null) : Flux { + val runIdParam = runId?.let { "&runId=$it" }.orEmpty() + val s = "$complexMissionUrl/api/satellites/$sat/modes?intervalStart=$tn&intervalEnd=$tk$runIdParam" + return webClientBuilder.build() + .get() + .uri(s) + .retrieve() + .bodyToFlux(SatelliteModeResponseDTO::class.java) + } + + fun processComplexPlan(request: ComplexPlanProcessRequestDTO): JsonNode = + webClientBuilder.build() + .post() + .uri("$complexMissionUrl/api/com-plan/process") + .header("X-Requested-By", "pcp-ui-service") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + fun complexPlanRuns(): JsonNode = + webClientBuilder.build() + .get() + .uri("$complexMissionUrl/api/com-plan/runs") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createArrayNode() + + fun complexPlanRun(id: Long): JsonNode = + webClientBuilder.build() + .get() + .uri("$complexMissionUrl/api/com-plan/runs/$id") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + fun complexPlanRunModes(id: Long): JsonNode = + webClientBuilder.build() + .get() + .uri("$complexMissionUrl/api/com-plan/runs/$id/modes") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createArrayNode() + + fun deleteComplexPlanRun(id: Long) { + webClientBuilder.build() + .delete() + .uri("$complexMissionUrl/api/com-plan/runs/$id") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .toBodilessEntity() + .block() + } + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } + + private fun SatelliteSummaryDTO.toLegacyInfo() = SatelliteInfoDTO( + noradId = id, + name = name, + red = visualization.red, + green = visualization.green, + blue = visualization.blue, + scanTLE = scanTle, + code = code, + typeCode = typeCode, + active = active + ) + + private fun SatelliteDTO.toLegacyInfo(): SatelliteInfoDTO { + return SatelliteInfoDTO( + noradId = id, + name = name, + red = visualization.red, + green = visualization.green, + blue = visualization.blue, + captureAngle = observationProfile?.captureAngle ?: 1.5, + scanTLE = scanTle, + maxSurveyDurationSeconds = observationProfile?.durationMaxSeconds, + mmiSeconds = observationProfile?.mmiSeconds, + code = code, + typeCode = typeCode, + active = active + ) + } + +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt new file mode 100644 index 0000000..fb77839 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt @@ -0,0 +1,194 @@ +package space.nstart.pcp.slots_service.service + +import org.locationtech.jts.geom.Polygon +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Autowired +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.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.czml.CZMLColor +import space.nstart.pcp.slots_service.czml.CZMLEntity +import space.nstart.pcp.slots_service.czml.CZMLMaterial +import space.nstart.pcp.slots_service.czml.CZMLPolygon +import space.nstart.pcp.slots_service.czml.CZMLPosition +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime + +@Service +class CoverageSchemeService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val wktParser = WKTParser() + + @Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}") + val url = "" + + @Autowired + private lateinit var earthService: EarthService + + @Autowired + private lateinit var complexMissionService: ComplexMissionService + + fun requestCoverageScheme( + requestId: String, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + satelliteIds: List, + rollStepDegrees: Double?, + minimumTechnologyOverlap: Double?, + strictContinuousCoverage: Boolean?, + allowPartialCoverage: Boolean?, + orientationToleranceDegrees: Double?, + includeDebugInfo: Boolean? + ): CoverageSchemeUiResponseDTO { + if (satelliteIds.isEmpty()) { + throw CustomErrorException("Для расчета схемы покрытия выберите хотя бы один спутник") + } + + val request = earthService.reqcells(requestId)?.request + ?: throw CustomErrorException("Заявка $requestId не найдена") + + val response = webClientBuilder.build() + .post() + .uri("$url/api/coverage-schemes/calculate") + .bodyValue( + CoverageSchemeCalculateRequestDTO( + polygonWkt = request.contour, + satelliteIds = satelliteIds, + timeStart = timeStart, + timeEnd = timeStop, + rollStepDegrees = rollStepDegrees, + minimumTechnologyOverlap = minimumTechnologyOverlap, + strictContinuousCoverage = strictContinuousCoverage ?: true, + allowPartialCoverage = allowPartialCoverage, + orientationToleranceDegrees = orientationToleranceDegrees, + includeDebugInfo = includeDebugInfo ?: true + ) + ) + .retrieve() + .onStatus({ status -> status.isError }) { httpResponse -> + httpResponse.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(CoverageSchemeResponseDTO::class.java) + .block() + ?: CoverageSchemeResponseDTO() + + val rows = response.coverageScheme + .mapIndexed { index, slot -> + val sequence = index + 1 + CoverageSchemeModeRowDTO( + entityId = buildCoverageModeEntityId(requestId, sequence, slot.satelliteId), + sequence = sequence, + satelliteId = slot.satelliteId, + tn = slot.tn, + tk = slot.tk, + roll = slot.roll, + revolutionSign = slot.revolutionSign, + contour = slot.contour + ) + } + + val satelliteById = rows + .map { it.satelliteId } + .distinct() + .associateWith { satelliteId -> complexMissionService.satellite(satelliteId).block() } + + val entities = buildList { + add(CZMLEntity("document", "req_cov_scheme_$requestId", "1.1")) + rows.mapNotNullTo(this) { row -> + row.toCzmlEntity(satelliteById[row.satelliteId]) + } + } + + return CoverageSchemeUiResponseDTO( + cov = entities, + slots = rows + ) + } + + private fun CoverageSchemeModeRowDTO.toCzmlEntity(satellite: SatelliteInfoDTO?): CZMLEntity? { + val geometry = runCatching { wktParser.parseWKT(contour) }.getOrNull() + if (geometry !is Polygon) { + return null + } + + val positions = mutableListOf() + geometry.coordinates.forEach { coordinate -> + positions.add(coordinate.x) + positions.add(coordinate.y) + positions.add(0.0) + } + + return CZMLEntity( + id = entityId, + name = "Режим схемы покрытия", + version = "1.1", + description = buildString { + append("Последовательность ${sequence}
") + append("КА №${satelliteId}
") + append("ДМВ ${tn}
") + append("Длительность ${java.time.Duration.between(tn, tk).seconds}
") + append("Крен ${roll ?: "-"}
") + append("Ветвь ${revolutionSign ?: "-"}") + if (!selectionReason.isNullOrBlank()) { + append("
Причина выбора ${selectionReason}") + } + }, + polygon = CZMLPolygon( + positions = CZMLPosition(cartographicDegrees = positions), + outlineWidth = 2.0, + fill = true, + outlineColor = satellite?.toOutlineColor() ?: CZMLColor(30, 144, 255, 255), + material = satellite?.toMaterial() ?: CZMLMaterial(30, 144, 255, 150) + ) + ) + } + + private fun SatelliteInfoDTO.toOutlineColor() = + CZMLColor(red.toInt(), green.toInt(), blue.toInt(), 255) + + private fun SatelliteInfoDTO.toMaterial() = + CZMLMaterial(red.toInt(), green.toInt(), blue.toInt(), 150) + + private fun buildCoverageModeEntityId(requestId: String, sequence: Int, satelliteId: Long) = + "coverage_mode_${requestId}_${satelliteId}_$sequence" + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText("") + node.hasNonNull("message") -> node["message"].asText("") + else -> text + } + }.getOrDefault(text) + } +} + +class CoverageSchemeUiResponseDTO( + val cov: List = listOf(), + val slots: List = listOf() +) + +class CoverageSchemeModeRowDTO( + val entityId: String = "", + val sequence: Int = 0, + val satelliteId: Long = 0, + val tn: LocalDateTime = LocalDateTime.now(), + val tk: LocalDateTime = LocalDateTime.now(), + val roll: Double? = null, + val revolutionSign: RevolutionSign? = null, + val contour: String = "", + val selectionReason: String? = null +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt new file mode 100644 index 0000000..d16a264 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt @@ -0,0 +1,122 @@ +package space.nstart.pcp.slots_service.service + +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.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ObjectMapper +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +@Service +class DynamicPlanService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.dynamic-plan-service:pcp-dynamic-plan-service}") + private lateinit var dynamicPlanUrl: String + + fun calculate(request: DynamicPlanCalculationRequestDTO): JsonNode = + webClientBuilder.build() + .post() + .uri("$dynamicPlanUrl/v1/main/calcPlan") + .header("X-Requested-By", "pcp-ui-service") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + fun getRun(runId: UUID): JsonNode = + webClientBuilder.build() + .get() + .uri("$dynamicPlanUrl/v1/main/calcPlan/$runId") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + fun getRuns(status: String?, limit: Int, offset: Int): JsonNode { + val statusParam = status + ?.takeIf { value -> value.isNotBlank() } + ?.let { value -> "&status=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" } + ?: "" + + return webClientBuilder.build() + .get() + .uri("$dynamicPlanUrl/v1/main/calcPlan/runs?limit=$limit&offset=$offset$statusParam") + .retrieve() + .onStatus({ statusCode -> statusCode.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createArrayNode() + } + + fun getResult(runId: UUID): JsonNode = + webClientBuilder.build() + .get() + .uri("$dynamicPlanUrl/v1/main/calcPlan/$runId/result") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + fun getRoutes(runId: UUID, limit: Int, offset: Int): JsonNode = + webClientBuilder.build() + .get() + .uri("$dynamicPlanUrl/v1/main/calcPlan/$runId/routes?limit=$limit&offset=$offset") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + fun getIntervals(runId: UUID, limit: Int, offset: Int): JsonNode = + webClientBuilder.build() + .get() + .uri("$dynamicPlanUrl/v1/main/calcPlan/$runId/intervals?limit=$limit&offset=$offset") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt new file mode 100644 index 0000000..26583e7 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt @@ -0,0 +1,298 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +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.Flux +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO +import java.util.UUID + + +@Service +/** + * Request/grid reader backed by pcp-request-service. + */ +class EarthService( + webClientBuilderProvider: ObjectProvider, + @param:Value("\${settings.request-service:\${settings.earth-grid-service:pcp-request-service}}") + private val url: String = "pcp-request-service", +) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + fun cells(importance: Double, countLat: Int? = null, countLong: Int? = null): Iterable { + /** + * countLat/countLong remain in the UI contract for compatibility. + * TODO: forward them when pcp-request-service restores aggregation for GET /v1/cells. + */ + val firstPage = cellsPage(importance, page = 0) + val items = firstPage.items.toMutableList() + + for (page in 1 until firstPage.totalPages) { + items += cellsPage(importance, page).items + } + + return items.map { cell -> cell.toEarthCellDto() } + } + + private fun cellsPage(importance: Double, page: Int): CellsListResponseDto = + webClientBuilder.build() + .get() + .uri(cellsUri(importance, page)) + .retrieve() + .bodyToMono(CellsListResponseDto::class.java) + .block() ?: CellsListResponseDto() + + private fun cellsUri(importance: Double, page: Int): String = + "$url/v1/cells?minImportance=$importance&page=$page&size=$CELLS_PAGE_SIZE" + + fun reqcells(id : String): RequestWithCellsDTO? = + webClientBuilder.build() + .get() + .uri("$url/v1/requests/$id/with-cells") + .retrieve() + .bodyToMono(RequestWithCellsResponseDto::class.java) + .block() + ?.toRequestWithCellsDto() + + fun reqs(): Flux { + val firstPage = requestsPage(page = 0) + val summaries = firstPage.items.toMutableList() + + for (page in 1 until firstPage.totalPages) { + summaries += requestsPage(page).items + } + + val requests = summaries.map { summary -> requestDetails(summary.id).toRequestDto() } + return Flux.fromIterable(requests) + } + + private fun requestsPage(page: Int): RequestsListResponseDto = + webClientBuilder.build() + .get() + .uri("$url/v1/requests?page=$page&size=$REQUESTS_PAGE_SIZE") + .retrieve() + .bodyToMono(RequestsListResponseDto::class.java) + .block() ?: RequestsListResponseDto() + + private fun requestDetails(id: UUID): RequestDetailsResponseDto = + webClientBuilder.build() + .get() + .uri("$url/v1/requests/$id") + .retrieve() + .bodyToMono(RequestDetailsResponseDto::class.java) + .block() ?: RequestDetailsResponseDto(id = id) + + fun addRequest(request: RequestDTO): RequestDTO? = + webClientBuilder.build() + .post() + .uri("$url/v1/requests") + .bodyValue(request.toCreateRequestRequestDto()) + .retrieve() + .bodyToMono(CreateRequestResponseDto::class.java) + .block() + ?.toRequestDto(request) + + private fun CellSummaryResponseDto.toEarthCellDto(): EarthCellDTO = + EarthCellDTO( + id = cellNum, + num = cellNum, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = contour, + ) + + private fun RequestDetailsResponseDto.toRequestDto(): RequestDTO = + RequestDTO( + requestId = id, + name = name, + importance = importance, + contour = geometry, + ) + + private fun RequestWithCellsResponseDto.toRequestWithCellsDto(): RequestWithCellsDTO = + RequestWithCellsDTO( + request = request.toRequestDto(), + cells = cells.map { cell -> cell.toEarthCellDto() }, + ) + + private fun RequestResponseDto.toRequestDto(): RequestDTO = + RequestDTO( + requestId = id, + name = name, + importance = importance, + contour = geometry, + ) + + private fun RequestDTO.toCreateRequestRequestDto(): CreateRequestRequestDto = + CreateRequestRequestDto( + id = UUID.randomUUID(), + name = name, + geometry = contour, + importance = importance, + beginDateTime = DEFAULT_BEGIN_DATE_TIME, + endDateTime = DEFAULT_END_DATE_TIME, + kpp = emptyList(), + highPriorityTransmit = false, + optics = DEFAULT_OPTICS, + rsa = null, + ) + + private fun CreateRequestResponseDto.toRequestDto(source: RequestDTO): RequestDTO = + RequestDTO( + requestId = id, + importance = importance, + contour = source.contour, + name = name, + ) + + private fun RequestCellResponseDto.toEarthCellDto(): EarthCellDTO = + EarthCellDTO( + id = cellNum, + num = cellNum, + importance = importance, + contour = contour, + ) + + private companion object { + const val CELLS_PAGE_SIZE = 500 + const val REQUESTS_PAGE_SIZE = 500 + const val DEFAULT_BEGIN_DATE_TIME = "2026-01-01T00:00:00Z" + const val DEFAULT_END_DATE_TIME = "2026-12-31T23:59:59Z" + val DEFAULT_OPTICS = OpticsParamsDto( + resultType = "PANCHROMATIC", + resolution = 5.0, + sunAngleMin = 10.0, + sunAngleMax = 90.0, + clouds = 100.0, + ) + } +} + +data class CreateRequestRequestDto( + val id: UUID, + val name: String, + val geometry: String, + val importance: Double, + val beginDateTime: String, + val endDateTime: String, + val kpp: List, + val highPriorityTransmit: Boolean, + val optics: OpticsParamsDto?, + val rsa: RsaParamsDto?, +) + +data class OpticsParamsDto( + val resultType: String, + val resolution: Double, + val sunAngleMin: Double? = null, + val sunAngleMax: Double? = null, + val clouds: Double? = null, +) + +data class RsaParamsDto( + val resultType: String, + val interferometry: Boolean? = null, + val polarisation: String, + val resolution: Double, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CreateRequestResponseDto( + val id: UUID = UUID(0L, 0L), + val name: String = "", + val status: String = "", + val surveyType: String = "", + val importance: Double = 0.0, + val message: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestWithCellsResponseDto( + val request: RequestResponseDto = RequestResponseDto(), + val cells: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestResponseDto( + val id: UUID = UUID(0L, 0L), + val name: String = "", + val status: String = "", + val surveyType: String = "", + val geometry: String = "", + val importance: Double = 0.0, + val beginDateTime: String = "", + val endDateTime: String = "", + val kpp: List = emptyList(), + val highPriorityTransmit: Boolean = false, + val optics: Any? = null, + val rsa: Any? = null, + val coverage: CoverageStateDto = CoverageStateDto(), + val createdAt: String = "", + val updatedAt: String = "", + val deletedAt: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestCellResponseDto( + val cellNum: Long = 0, + val coveragePercent: Double = 0.0, + val importance: Double = 0.0, + val contour: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CoverageStateDto( + val requiredPercent: Double = 0.0, + val currentPercent: Double = 0.0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestsListResponseDto( + val items: List = emptyList(), + val page: Int = 0, + val size: Int = 0, + val totalItems: Long = 0, + val totalPages: Int = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestSummaryResponseDto( + val id: UUID, + val name: String = "", + val status: String = "", + val surveyType: String = "", + val importance: Double = 0.0, + val beginDateTime: String = "", + val endDateTime: String = "", + val kpp: List = emptyList(), + val highPriorityTransmit: Boolean = false, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestDetailsResponseDto( + val id: UUID, + val name: String = "", + val geometry: String = "", + val importance: Double = 0.0, +) + +data class CellsListResponseDto( + val items: List = emptyList(), + val page: Int = 0, + val size: Int = 0, + val totalItems: Long = 0, + val totalPages: Int = 0, +) + +data class CellSummaryResponseDto( + val cellNum: Long = 0, + val latitude: Double = 0.0, + val longitude: Double = 0.0, + val importance: Double = 0.0, + val contour: String = "", +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/GroupStateService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/GroupStateService.kt new file mode 100644 index 0000000..f0bd25d --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/GroupStateService.kt @@ -0,0 +1,160 @@ +package space.nstart.pcp.slots_service.service + +import ballistics.Ballistics +import ballistics.types.OrbitalPoint +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO +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.groupstate.GroupStateAvailabilityDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateDetailsDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixCellDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixRowDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSatelliteDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSummaryDTO +import java.time.LocalDateTime +import kotlin.math.abs + +@Service +class GroupStateService( + private val satelliteCatalogService: SatelliteCatalogService, + private val ballisticsService: BallisticsService +) { + + fun groupSummaries(): List { + val groups = satelliteCatalogService.allGroups() + val satellitesById = satelliteCatalogService.allSatellites().associateBy { it.id } + val availabilityByNoradId = ballisticsService.orbitAvailability().associateBy { it.satelliteId } + + return groups.map { group -> + GroupStateSummaryDTO( + groupId = group.id, + groupName = group.name, + satelliteCount = group.satelliteIds.size, + availableInterval = resolveAvailability(group, satellitesById, availabilityByNoradId) + ) + } + } + + fun groupState(groupId: Long, time: LocalDateTime?): GroupStateDetailsDTO { + val group = satelliteCatalogService.group(groupId) + val satellitesById = satelliteCatalogService.allSatellites().associateBy { it.id } + val availabilityByNoradId = ballisticsService.orbitAvailability().associateBy { it.satelliteId } + val groupSatellites = resolveGroupSatellites(group, satellitesById) + val availableInterval = resolveAvailability(group, satellitesById, availabilityByNoradId) + + if (availableInterval == null) { + return GroupStateDetailsDTO( + groupId = group.id, + groupName = group.name, + availableInterval = null + ) + } + + val calculationTime = time ?: availableInterval.timeStart + if (calculationTime < availableInterval.timeStart || calculationTime > availableInterval.timeStop) { + throw CustomErrorException( + "Время $calculationTime не входит в доступный интервал ${availableInterval.timeStart} - ${availableInterval.timeStop}" + ) + } + + val satellites = groupSatellites.map { satellite -> + val noradId = satellite.id + ?: throw CustomErrorException("Для спутника ${satellite.id} не задан NORAD ID") + val point = ballisticsService.exactTimePoint(noradId, calculationTime) + val keplerParams = Ballistics().calculateKeplerParams(point.toOrbitalPoint()) + GroupStateSatelliteDTO( + satelliteId = satellite.id, + noradId = noradId, + code = satellite.code, + name = satellite.name, + omegabDeg = normalizeDegrees360(Math.toDegrees(keplerParams.omegab)), + uDeg = normalizeDegrees360(Math.toDegrees(keplerParams.u)) + ) + } + + val matrix = satellites.map { row -> + GroupStateMatrixRowDTO( + satelliteId = row.satelliteId, + cells = satellites.map { column -> + GroupStateMatrixCellDTO( + rowSatelliteId = row.satelliteId, + columnSatelliteId = column.satelliteId, + deltaOmegabDeg = normalizeDegrees180(column.omegabDeg - row.omegabDeg), + deltaUDeg = normalizeDegrees180(column.uDeg - row.uDeg) + ) + } + ) + } + + return GroupStateDetailsDTO( + groupId = group.id, + groupName = group.name, + availableInterval = availableInterval, + calculationTime = calculationTime, + satellites = satellites, + matrix = matrix + ) + } + + private fun resolveGroupSatellites( + group: SatelliteGroupDTO, + satellitesById: Map + ): List = group.satelliteIds.mapNotNull { satellitesById[it] } + + private fun resolveAvailability( + group: SatelliteGroupDTO, + satellitesById: Map, + availabilityByNoradId: Map + ): GroupStateAvailabilityDTO? { + val groupSatellites = resolveGroupSatellites(group, satellitesById) + if (group.satelliteIds.isEmpty() || groupSatellites.size != group.satelliteIds.size) { + return null + } + + val intervals = groupSatellites.map { satellite -> + val noradId = satellite.id + availabilityByNoradId[noradId] ?: return null + } + if (intervals.isEmpty()) { + return null + } + + // Общее время расчета ОГ существует только там, где пересекаются интервалы всех спутников. + val timeStart = intervals.maxOf { it.timeStart } + val timeStop = intervals.minOf { it.timeStop } + return if (timeStart <= timeStop) { + GroupStateAvailabilityDTO(timeStart = timeStart, timeStop = timeStop) + } else { + null + } + } + + private fun OrbPointDTO.toOrbitalPoint(): OrbitalPoint = + OrbitalPoint( + t = fromDateTime(time), + vit = revolution.toInt(), + r = Vector3D(x, y, z), + v = Vector3D(vx, vy, vz) + ) + + private fun normalizeDegrees360(value: Double): Double { + var normalized = value % 360.0 + if (normalized < 0) { + normalized += 360.0 + } + return normalized + } + + private fun normalizeDegrees180(value: Double): Double { + var normalized = normalizeDegrees360(value) + if (normalized > 180.0) { + normalized -= 360.0 + } + return if (abs(normalized) < 1e-9) 0.0 else normalized + } +} 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 new file mode 100644 index 0000000..b6802de --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt @@ -0,0 +1,116 @@ +package space.nstart.pcp.slots_service.service + +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 org.springframework.web.reactive.function.client.bodyToMono +import org.springframework.web.reactive.function.client.bodyToFlux +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO +import space.nstart.pcp.slots_service.dto.currentplans.CurrentPlansMissionPageDTO +import java.util.UUID +import java.time.LocalDateTime + + +@Service +class MissionService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.mission-service:pcp-complex-mission-service}") + val url = "" + + fun plan(sat : Long, tn : LocalDateTime, tk : LocalDateTime) = + webClientBuilder.build() + .get() + .uri("$url/api/missions/modes/satellite?satellite_id=$sat&timeStart=$tn&timeStop=$tk") + .retrieve() + .bodyToFlux() + + fun allMissions(): List = + webClientBuilder.build() + .get() + .uri("$url/api/missions") + .retrieve() + .bodyToFlux(MissionResponseDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun missionsBySatellite(satelliteId: Long, page: Int, size: Int): CurrentPlansMissionPageDTO { + val normalizedPage = page.coerceAtLeast(0) + val normalizedSize = size.coerceIn(1, 100) + val filtered = allMissions() + .asSequence() + .filter { it.satelliteId == satelliteId } + .sortedWith( + compareByDescending { it.missionStart ?: it.missionStop ?: LocalDateTime.MIN } + .thenByDescending { it.missionStop ?: LocalDateTime.MIN } + .thenByDescending { it.missionId.toString() } + ) + .toList() + + val from = (normalizedPage * normalizedSize).coerceAtMost(filtered.size) + val to = (from + normalizedSize).coerceAtMost(filtered.size) + val totalPages = if (filtered.isEmpty()) 0 else ((filtered.size - 1) / normalizedSize) + 1 + + return CurrentPlansMissionPageDTO( + satelliteId = satelliteId, + page = normalizedPage, + size = normalizedSize, + totalElements = filtered.size, + totalPages = totalPages, + items = filtered.subList(from, to) + ) + } + + + fun createMission(body: MissionRequestDTO): MissionResponseDTO = + webClientBuilder.build() + .post() + .uri("$url/api/missions") + .bodyValue(body) + .retrieve() + .bodyToMono() + .block() + ?: error("Mission service returned empty response while creating mission") + + fun calculateSurveys(missionId: UUID, comPlanSnapshotId: Long? = null): MissionSurveyCalculationResponseDTO { + val targetUrl = buildString { + append("$url/api/missions/$missionId/surveys/calculate") + if (comPlanSnapshotId != null) { + append("?comPlanSnapshotId=") + append(comPlanSnapshotId) + } + } + return webClientBuilder.build() + .post() + .uri(targetUrl) + .retrieve() + .bodyToMono() + .block() + ?: error("Mission service returned empty survey calculation response") + } + + fun calculateDrops(missionId: UUID) { + webClientBuilder.build() + .post() + .uri("$url/api/missions/$missionId/drops/calculate") + .retrieve() + .toBodilessEntity() + .block() + } + + fun modesByMission(missionId: UUID): List = + webClientBuilder.build() + .get() + .uri("$url/api/missions/$missionId/modes") + .retrieve() + .bodyToFlux() + .collectList() + .block() + ?: emptyList() + +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogService.kt new file mode 100644 index 0000000..46916f0 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogService.kt @@ -0,0 +1,187 @@ +package space.nstart.pcp.slots_service.service + +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.satellite.SatelliteCreateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +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.SatelliteGroupUpdateDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import tools.jackson.databind.ObjectMapper + +@Service +class SatelliteCatalogService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.satellite-catalog-service:\${settings.complex-mission-service:pcp-satellite-catalog-service}}") + val satelliteCatalogUrl = "" + + fun allSatellites(): List = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogUrl/api/satellites") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToFlux(SatelliteSummaryDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun satellite(id: Long): SatelliteDTO = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogUrl/api/satellites/$id") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось получить спутник $id: пустой ответ") + + fun createSatellite(request: SatelliteCreateDTO): SatelliteDTO = + webClientBuilder.build() + .post() + .uri("$satelliteCatalogUrl/api/satellites") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось создать спутник: пустой ответ") + + fun updateSatellite(id: Long, request: SatelliteUpdateDTO): SatelliteDTO = + webClientBuilder.build() + .put() + .uri("$satelliteCatalogUrl/api/satellites/$id") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось обновить спутник $id: пустой ответ") + + fun deleteSatellite(id: Long) { + webClientBuilder.build() + .delete() + .uri("$satelliteCatalogUrl/api/satellites/$id") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .toBodilessEntity() + .block() + } + + fun saveObservationProfile(id: Long, request: SatelliteObservationProfileDTO, create: Boolean): SatelliteObservationProfileDTO { + val methodSpec = if (create) webClientBuilder.build().post() else webClientBuilder.build().put() + return methodSpec + .uri("$satelliteCatalogUrl/api/satellites/$id/observation-profile") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteObservationProfileDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось сохранить observation-profile для КА $id") + } + + fun deleteObservationProfile(id: Long) { + webClientBuilder.build() + .delete() + .uri("$satelliteCatalogUrl/api/satellites/$id/observation-profile") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .toBodilessEntity() + .block() + } + + fun saveSlotProfile(id: Long, request: SatelliteSlotProfileDTO, create: Boolean): SatelliteSlotProfileDTO { + val methodSpec = if (create) webClientBuilder.build().post() else webClientBuilder.build().put() + return methodSpec + .uri("$satelliteCatalogUrl/api/satellites/$id/slot-profile") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteSlotProfileDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось сохранить slot-profile для КА $id") + } + + fun deleteSlotProfile(id: Long) { + webClientBuilder.build() + .delete() + .uri("$satelliteCatalogUrl/api/satellites/$id/slot-profile") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .toBodilessEntity() + .block() + } + + fun allGroups(): List = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogUrl/api/satellite-groups") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToFlux(SatelliteGroupDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun group(id: Long): SatelliteGroupDTO = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogUrl/api/satellite-groups/$id") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteGroupDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось получить группировку $id: пустой ответ") + + fun createGroup(request: SatelliteGroupCreateDTO): SatelliteGroupDTO = + webClientBuilder.build() + .post() + .uri("$satelliteCatalogUrl/api/satellite-groups") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteGroupDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось создать группировку: пустой ответ") + + fun updateGroup(id: Long, request: SatelliteGroupUpdateDTO): SatelliteGroupDTO = + webClientBuilder.build() + .put() + .uri("$satelliteCatalogUrl/api/satellite-groups/$id") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(SatelliteGroupDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось обновить группировку $id: пустой ответ") + + fun deleteGroup(id: Long) { + webClientBuilder.build() + .delete() + .uri("$satelliteCatalogUrl/api/satellite-groups/$id") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .toBodilessEntity() + .block() + } + + 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.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt new file mode 100644 index 0000000..acf13a5 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt @@ -0,0 +1,75 @@ +package space.nstart.pcp.slots_service.service + +import org.springframework.stereotype.Service +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO +import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO +import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO +import java.time.LocalDateTime + +@Service +class SatellitePdcmService( + private val satelliteCatalogService: SatelliteCatalogService, + private val ballisticsService: BallisticsService +) { + + fun satelliteSummaries(): List = satelliteSummaries(LocalDateTime.now()) + + fun satelliteSummaries(referenceTime: LocalDateTime): List { + val satellites = satelliteCatalogService.allSatellites() + val availabilityBySatelliteId = ballisticsService.orbitAvailability().associateBy { it.satelliteId } + + return satellites + .map { satellite -> + val availability = availabilityBySatelliteId[satellite.id] + SatellitePdcmSummaryDTO( + satelliteId = satellite.id, + code = satellite.code, + name = satellite.name, + activeInterval = availability.isActive(referenceTime), + availableInterval = availability?.toDto() + ) + } + .sortedWith( + compareByDescending { it.activeInterval } + .thenBy { it.name.lowercase() } + .thenBy { it.satelliteId } + ) + } + + fun satelliteDetails(satelliteId: Long): SatellitePdcmDetailsDTO = + satelliteDetails(satelliteId, LocalDateTime.now()) + + fun satelliteDetails(satelliteId: Long, referenceTime: LocalDateTime): SatellitePdcmDetailsDTO { + val satellites = satelliteCatalogService.allSatellites() + val satellite = satellites.firstOrNull { it.id == satelliteId } + ?: throw CustomErrorException("Спутник $satelliteId не найден") + val availability = ballisticsService.orbitAvailability().firstOrNull { it.satelliteId == satelliteId } + val ascNodes = availability?.let { + // Для страницы ПДЦМ используется ID спутника из каталога, без перехода к NORAD ID. + ballisticsService.ascNodes(satellite.id, it.timeStart, it.timeStop).collectList().block() + } ?: emptyList() + + return SatellitePdcmDetailsDTO( + satelliteId = satellite.id, + code = satellite.code, + name = satellite.name, + activeInterval = availability.isActive(referenceTime), + availableInterval = availability?.toDto(), + ascNodes = ascNodes ?: emptyList() + ) + } + + private fun SatelliteOrbitAvailabilityDTO?.isActive(referenceTime: LocalDateTime): Boolean { + if (this == null) { + return false + } + return !referenceTime.isBefore(timeStart) && !referenceTime.isAfter(timeStop) + } + + private fun SatelliteOrbitAvailabilityDTO.toDto() = SatellitePdcmAvailabilityDTO( + timeStart = timeStart, + timeStop = timeStop + ) +} 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 new file mode 100644 index 0000000..d86b4f0 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -0,0 +1,285 @@ +package space.nstart.pcp.slots_service.service + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.util.UriComponentsBuilder +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToFlux +import space.nstart.pcp.pcp_types_lib.dto.ballistics.DurationOnRevDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.ballistics.IntervalDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO +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.requests.slots.BookedSlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +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 java.time.LocalDateTime + +@Service +class SlotService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.slots-service:slots-service}") + val url = "" + + fun slots( + id : String, + tn : LocalDateTime, + tk : LocalDateTime, + sign : RevolutionSign?, + cov : Boolean?, + satellites : List? = null, + sun : Double? = null + ) = + webClientBuilder.build() + .get() + .uri( + UriComponentsBuilder + .fromUriString("$url/api/slots/request-cover") + .queryParam("requestId", id) + .queryParam("timeStart", tn) + .queryParam("timeStop", tk) + .queryParamIfPresent("revSign", java.util.Optional.ofNullable(sign)) + .queryParam("cov", cov) + .queryParamIfPresent("sun", java.util.Optional.ofNullable(sun)) + .apply { + satellites?.forEach { queryParam("satellites", it) } + } + .build() + .toUriString() + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux() + + + fun plan(id : Long, tn : LocalDateTime, tk : LocalDateTime) = + webClientBuilder.build() + .get() + .uri("$url/api/slots/mission/satellite?satellite_id=$id&timeStart=$tn&timeStop=$tk") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux< SurveySlotDTO>() + + fun allSlotsByInterval(id: Long, tn: LocalDateTime, tk: LocalDateTime): List = + webClientBuilder.build() + .get() + .uri("$url/api/slots/interval?satelliteId=$id&timeStart=$tn&timeStop=$tk") + .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 slotCalculationSummaries(satelliteIds: List): List { + if (satelliteIds.isEmpty()) { + return emptyList() + } + + return webClientBuilder.build() + .get() + .uri( + UriComponentsBuilder + .fromUriString("$url/api/slots/calculation-summary") + .apply { + satelliteIds.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(SlotCalculationSummaryDTO::class.java) + .collectList() + .block() ?: emptyList() + } + + fun slotCalculationSummary(satelliteId: Long): SlotCalculationSummaryDTO = + slotCalculationSummaries(listOf(satelliteId)).firstOrNull() + ?: SlotCalculationSummaryDTO(satelliteId = satelliteId) + + fun startSlotCalculation(satelliteId: Long, slotProfile: SatelliteSlotProfileDTO) { + webClientBuilder.build() + .post() + .uri( + UriComponentsBuilder + .fromUriString("$url/api/slots/test-init") + .queryParam("sats", satelliteId) + .queryParam("slotDuration", slotProfile.slotDuration) + .queryParam("dailyDuration", slotProfile.durationCalcDays) + .queryParam("revolutionDuration", slotProfile.cycleRevs) + .queryParam("recover", false) + .build() + .toUriString() + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .toBodilessEntity() + .block() + } + + + fun bookReq(req: BookingRequestDTO): List = + webClientBuilder.build() + .post() + .uri("$url/api/slots/booking/request") + .bodyValue(req) + .retrieve() + .bodyToFlux() + .collectList() + .block() ?: emptyList() + + fun cancelReq(requestId: String): Int = + webClientBuilder.build() + .delete() + .uri("$url/api/slots/booking/request?requestId=$requestId") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(Int::class.java) + .block() ?: 0 + + fun rvaCommon(satelliteId: Long, duration: Long, stations: List): List = + webClientBuilder.build() + .post() + .uri("$url/api/satellite/rva-common/$satelliteId/$duration") + .bodyValue(stations) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux(RadioVisibilityAreaDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun rvaMerge(satelliteId: Long, duration: Long, stations: List): List = + webClientBuilder.build() + .post() + .uri("$url/api/satellite/rva-merge/$satelliteId/$duration") + .bodyValue(stations) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux(IntervalDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun rvaMergeOnRev(satelliteId: Long, duration: Long, stations: List): List = + webClientBuilder.build() + .post() + .uri("$url/api/satellite/rva-merge-on-rev/$satelliteId/$duration") + .bodyValue(stations) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux(DurationOnRevDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun satelliteInitialConditions(satelliteId: Long): SatelliteICDTO? = + webClientBuilder.build() + .get() + .uri("$url/api/satellite-ic/$satelliteId") + .exchangeToMono { response -> + when { + response.statusCode().is2xxSuccessful -> response.bodyToMono(SatelliteICDTO::class.java) + response.statusCode().is4xxClientError -> response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> + val message = extractErrorMessage(body) + if (message.contains("не зарегистрированы")) { + Mono.empty() + } else { + Mono.error(CustomErrorException(message)) + } + } + + else -> response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + } + .block() + + fun saveSatelliteInitialConditions( + satelliteId: Long, + request: InitialConditionsDTO, + create: Boolean + ): SatelliteICDTO { + val methodSpec = if (create) webClientBuilder.build().post() else webClientBuilder.build().put() + return methodSpec + .uri( + if (create) "$url/api/satellite-ic" + else "$url/api/satellite-ic/$satelliteId" + ) + .bodyValue( + if (create) SatelliteICDTO(satelliteId = satelliteId, ic = request) + else request + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(SatelliteICDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось сохранить начальные условия для КА $satelliteId") + } + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) return "error" + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/StationService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/StationService.kt new file mode 100644 index 0000000..e437b07 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/StationService.kt @@ -0,0 +1,77 @@ +package space.nstart.pcp.slots_service.service + +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.StationDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import tools.jackson.databind.ObjectMapper + +@Service +class StationService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.stations-service:stations-service}") + val url = "" + + fun stations() = + webClientBuilder.build() + .get() + .uri("$url/api/stations") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToFlux(StationDTO::class.java) + + + fun station(id : String) = + webClientBuilder.build() + .get() + .uri("$url/api/stations/$id") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(StationDTO::class.java) + + fun allStations(): List = + stations().collectList().block() ?: emptyList() + + fun saveStation(request: StationDTO): StationDTO = + webClientBuilder.build() + .post() + .uri("$url/api/stations") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToMono(StationDTO::class.java) + .block() ?: throw CustomErrorException("Не удалось сохранить станцию: пустой ответ") + + fun deleteStation(id: String) { + webClientBuilder.build() + .delete() + .uri("$url/api/stations/$id") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .toBodilessEntity() + .block() + } + + 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.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/pcp-ui-service/src/main/resources/application.yaml b/services/pcp-ui-service/src/main/resources/application.yaml new file mode 100644 index 0000000..78562ba --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: pcp-ui-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} diff --git a/services/pcp-ui-service/src/main/resources/static/catalog_scripts.js b/services/pcp-ui-service/src/main/resources/static/catalog_scripts.js new file mode 100644 index 0000000..26e668b --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/catalog_scripts.js @@ -0,0 +1,827 @@ +(function () { + const pageRoot = document.getElementById('catalog-page'); + if (!pageRoot) { + return; + } + + const page = pageRoot.dataset.page; + const apiBase = '/api/catalog'; + + function showAlert(containerId, message, type = 'danger') { + const container = document.getElementById(containerId); + if (!container) return; + if (!message) { + container.innerHTML = ''; + return; + } + container.innerHTML = ``; + } + + async function requestJson(url, options = {}) { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json' + }, + ...options + }); + + if (response.status === 204) { + return null; + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const payload = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (payload && typeof payload === 'object') { + if (payload.error) { + throw new Error(payload.error); + } + throw new Error(Object.values(payload).join('; ')); + } + throw new Error(payload || `HTTP ${response.status}`); + } + + return payload; + } + + function toNullableNumber(value) { + return value === '' || value === null || value === undefined ? null : Number(value); + } + + 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 parseDateTimeInput(value, fieldName, required = false) { + const text = value?.trim() ?? ''; + if (!text) { + if (required) { + throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:mm:ss.zzz`); + } + return null; + } + + const displayMatch = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2}):(\d{2})[.,](\d{1,3})$/); + if (displayMatch) { + const [, day, month, year, hour, minute, second, millis] = displayMatch; + validateDateTimeParts(day, month, year, hour, minute, second, fieldName); + return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis.padEnd(3, '0')}`; + } + + const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,3}))?)?$/); + if (isoMatch) { + const [, year, month, day, hour, minute, second = '00', millis = '000'] = isoMatch; + validateDateTimeParts(day, month, year, hour, minute, second, fieldName); + return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis.padEnd(3, '0')}`; + } + + throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:mm:ss.zzz`); + } + + function validateDateTimeParts(day, month, year, hour, minute, second, fieldName) { + const date = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); + const isValid = date.getFullYear() === Number(year) && + date.getMonth() === Number(month) - 1 && + date.getDate() === Number(day) && + date.getHours() === Number(hour) && + date.getMinutes() === Number(minute) && + date.getSeconds() === Number(second); + + if (!isValid) { + throw new Error(`${fieldName}: некорректная дата или время`); + } + } + + function formatDisplayDateTime(value) { + if (!value) return '—'; + return formatDateTimeInput(value); + } + + function buildRepeatedQueryParam(name, values) { + const params = new URLSearchParams(); + values.forEach(value => params.append(name, String(value))); + return params.toString(); + } + + function parseAngles(text) { + const lines = text + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + return lines.map((line, index) => { + const parts = line.split(',').map(part => part.trim()); + if (parts.length !== 2) { + throw new Error(`Строка ${index + 1} должна содержать два числа через запятую`); + } + const angleBegin = Number(parts[0]); + const angleEnd = Number(parts[1]); + if (Number.isNaN(angleBegin) || Number.isNaN(angleEnd)) { + throw new Error(`Строка ${index + 1} содержит некорректный диапазон углов`); + } + return { angleBegin, angleEnd }; + }); + } + + function renderChips(items) { + if (!items.length) { + return 'Пусто'; + } + return items.map(item => `${item}`).join(''); + } + + function initGroupsPage() { + const state = { + groups: [], + satellites: [], + selectedGroupId: null + }; + + const groupTableBody = document.getElementById('groups-table-body'); + const groupForm = document.getElementById('group-form'); + const satelliteChecklist = document.getElementById('group-satellite-checklist'); + const title = document.getElementById('group-form-title'); + + function renderSatelliteChecklist() { + satelliteChecklist.innerHTML = ''; + state.satellites.forEach(satellite => { + const wrapper = document.createElement('div'); + wrapper.className = 'form-check'; + wrapper.innerHTML = ` + + + `; + satelliteChecklist.appendChild(wrapper); + }); + } + + function selectedSatelliteIds() { + return Array.from(satelliteChecklist.querySelectorAll('input:checked')) + .map(input => Number(input.value)); + } + + function setSelectedSatelliteIds(ids) { + const selected = new Set(ids); + satelliteChecklist.querySelectorAll('input').forEach(input => { + input.checked = selected.has(Number(input.value)); + }); + } + + function renderGroups() { + groupTableBody.innerHTML = ''; + if (!state.groups.length) { + groupTableBody.innerHTML = 'Группировки не найдены'; + return; + } + + state.groups.forEach(group => { + const tr = document.createElement('tr'); + if (group.id === state.selectedGroupId) { + tr.classList.add('table-active'); + } + tr.innerHTML = ` + ${group.id} + ${group.name} + ${group.satelliteIds.length} + ${renderChips(group.satelliteIds.map(String))} + `; + tr.addEventListener('click', () => selectGroup(group.id)); + groupTableBody.appendChild(tr); + }); + } + + function resetForm() { + groupForm.reset(); + document.getElementById('group-id').value = ''; + state.selectedGroupId = null; + setSelectedSatelliteIds([]); + title.textContent = 'Новая группировка'; + renderGroups(); + showAlert('groups-alert', ''); + } + + function fillForm(group) { + document.getElementById('group-id').value = String(group.id); + document.getElementById('group-name').value = group.name; + setSelectedSatelliteIds(group.satelliteIds); + title.textContent = `Группировка #${group.id}`; + } + + function selectGroup(groupId) { + const group = state.groups.find(item => item.id === groupId); + if (!group) return; + state.selectedGroupId = groupId; + fillForm(group); + renderGroups(); + showAlert('groups-alert', ''); + } + + async function loadData() { + showAlert('groups-alert', ''); + const [satellites, groups] = await Promise.all([ + requestJson(`${apiBase}/satellites`), + requestJson(`${apiBase}/groups`) + ]); + state.satellites = satellites; + state.groups = groups; + renderSatelliteChecklist(); + renderGroups(); + if (state.selectedGroupId) { + const selected = state.groups.find(item => item.id === state.selectedGroupId); + if (selected) { + fillForm(selected); + } else { + resetForm(); + } + } + } + + document.getElementById('groups-refresh').addEventListener('click', async () => { + try { + await loadData(); + } catch (error) { + showAlert('groups-alert', error.message); + } + }); + + document.getElementById('groups-new').addEventListener('click', resetForm); + + groupForm.addEventListener('submit', async event => { + event.preventDefault(); + try { + const groupId = document.getElementById('group-id').value; + const payload = { + name: document.getElementById('group-name').value.trim(), + satelliteIds: selectedSatelliteIds() + }; + + const result = groupId + ? await requestJson(`${apiBase}/groups/${groupId}`, { method: 'PUT', body: JSON.stringify(payload) }) + : await requestJson(`${apiBase}/groups`, { method: 'POST', body: JSON.stringify(payload) }); + + state.selectedGroupId = result.id; + await loadData(); + showAlert('groups-alert', 'Группировка сохранена', 'success'); + } catch (error) { + showAlert('groups-alert', error.message); + } + }); + + document.getElementById('groups-delete').addEventListener('click', async () => { + const groupId = document.getElementById('group-id').value; + if (!groupId) { + showAlert('groups-alert', 'Сначала выберите группировку для удаления'); + return; + } + if (!window.confirm(`Удалить группировку #${groupId}?`)) { + return; + } + try { + await requestJson(`${apiBase}/groups/${groupId}`, { method: 'DELETE' }); + resetForm(); + await loadData(); + showAlert('groups-alert', 'Группировка удалена', 'success'); + } catch (error) { + showAlert('groups-alert', error.message); + } + }); + + loadData().catch(error => showAlert('groups-alert', error.message)); + } + + function initSatellitesPage() { + const state = { + summaries: [], + selectedSatelliteId: null, + selectedSatellite: null, + selectedSatelliteInitialConditions: null, + selectedSlotCalculationSummary: null, + createDraftMode: false, + slotSummariesBySatelliteId: new Map() + }; + + const tableBody = document.getElementById('satellites-table-body'); + const searchInput = document.getElementById('satellite-search'); + + function updateColorPreview() { + const red = Number(document.getElementById('satellite-red').value || 0); + const green = Number(document.getElementById('satellite-green').value || 0); + const blue = Number(document.getElementById('satellite-blue').value || 0); + document.getElementById('satellite-color-preview').style.background = `rgb(${red}, ${green}, ${blue})`; + } + + function renderList() { + const query = searchInput.value.trim().toLowerCase(); + const items = state.summaries.filter(item => + !query || + String(item.id).includes(query) || + item.name.toLowerCase().includes(query) || + item.code.toLowerCase().includes(query) + ); + + tableBody.innerHTML = ''; + if (!items.length) { + tableBody.innerHTML = 'Спутники не найдены'; + return; + } + + items.forEach(satellite => { + const slotSummary = state.slotSummariesBySatelliteId.get(Number(satellite.id)); + const tr = document.createElement('tr'); + if (satellite.id === state.selectedSatelliteId) { + tr.classList.add('table-active'); + } + tr.innerHTML = ` + ${satellite.id} + ${satellite.code} + ${satellite.name} + ${satellite.typeCode} + ${renderSlotIndicator(slotSummary)} + `; + tr.addEventListener('click', () => selectSatellite(satellite.id)); + tableBody.appendChild(tr); + }); + } + + function renderSlotIndicator(summary) { + const calculated = Boolean(summary?.calculated); + const title = calculated + ? 'Рассчитанные слоты есть' + : 'Рассчитанные слоты отсутствуют'; + return ``; + } + + function renderSlotCalculationSummary(summary) { + const container = document.getElementById('slot-calculation-summary'); + if (!container) return; + + if (!summary?.calculated) { + container.innerHTML = ` +
+ Рассчитанные слоты отсутствуют. +
+ `; + return; + } + + container.innerHTML = ` +
Рассчитанные слоты
+
+
+
Минимальное время
+
${formatDisplayDateTime(summary.minTime)}
+
+
+
Максимальное время
+
${formatDisplayDateTime(summary.maxTime)}
+
+
+
Интервал витков
+
${summary.revolutionInterval ?? '—'}
+
+
+ `; + } + + function resetBaseForm() { + document.getElementById('satellite-form').reset(); + document.getElementById('satellite-id').disabled = false; + document.getElementById('satellite-active').checked = true; + document.getElementById('satellite-scan-tle').checked = false; + document.getElementById('satellite-red').value = 255; + document.getElementById('satellite-green').value = 0; + document.getElementById('satellite-blue').value = 0; + document.getElementById('satellite-form-title').textContent = 'Новый спутник'; + state.selectedSatelliteId = null; + state.selectedSatellite = null; + state.selectedSatelliteInitialConditions = null; + state.selectedSlotCalculationSummary = null; + state.createDraftMode = false; + clearProfiles(); + updateColorPreview(); + renderList(); + } + + function clearProfiles() { + document.getElementById('observation-form').reset(); + document.getElementById('slot-form').reset(); + document.getElementById('slot-ic-form').reset(); + document.getElementById('slot-default-angles').value = ''; + document.getElementById('slot-tn-calc').value = ''; + document.getElementById('slot-ic-time').value = ''; + document.getElementById('slot-ic-status').textContent = 'Начальные условия не заданы.'; + renderSlotCalculationSummary(null); + } + + function fillProfiles(satellite, initialConditions, slotCalculationSummary) { + const observation = satellite.observationProfile; + document.getElementById('observation-capture-angle').value = observation?.captureAngle ?? ''; + document.getElementById('observation-sun-angle-min').value = observation?.sunAngleMin ?? ''; + document.getElementById('observation-duration-min').value = observation?.durationMinSeconds ?? ''; + document.getElementById('observation-duration-max').value = observation?.durationMaxSeconds ?? ''; + document.getElementById('observation-mmi').value = observation?.mmiSeconds ?? ''; + document.getElementById('observation-daily-max').value = observation?.dailyMaxDurationSeconds ?? ''; + document.getElementById('observation-revolution-max').value = observation?.revolutionMaxDurationSeconds ?? ''; + + const slotProfile = satellite.slotProfile; + document.getElementById('slot-cycle-revs').value = slotProfile?.cycleRevs ?? ''; + document.getElementById('slot-tn-calc').value = formatDateTimeInput(slotProfile?.tnCalc); + document.getElementById('slot-duration').value = slotProfile?.slotDuration ?? ''; + document.getElementById('slot-duration-calc-days').value = slotProfile?.durationCalcDays ?? ''; + document.getElementById('slot-max-chain-length').value = slotProfile?.maxChainLength ?? ''; + document.getElementById('slot-default-angles').value = slotProfile?.defaultAngles?.map(angle => `${angle.angleBegin}, ${angle.angleEnd}`).join('\n') ?? ''; + + document.getElementById('slot-ic-time').value = formatDateTimeInput(initialConditions?.ic?.orbPoint?.time); + document.getElementById('slot-ic-revolution').value = initialConditions?.ic?.orbPoint?.revolution ?? ''; + document.getElementById('slot-ic-s-ball').value = initialConditions?.ic?.sBall ?? ''; + document.getElementById('slot-ic-f81').value = initialConditions?.ic?.f81 ?? ''; + document.getElementById('slot-ic-x').value = initialConditions?.ic?.orbPoint?.x ?? ''; + document.getElementById('slot-ic-y').value = initialConditions?.ic?.orbPoint?.y ?? ''; + document.getElementById('slot-ic-z').value = initialConditions?.ic?.orbPoint?.z ?? ''; + document.getElementById('slot-ic-vx').value = initialConditions?.ic?.orbPoint?.vx ?? ''; + document.getElementById('slot-ic-vy').value = initialConditions?.ic?.orbPoint?.vy ?? ''; + document.getElementById('slot-ic-vz').value = initialConditions?.ic?.orbPoint?.vz ?? ''; + document.getElementById('slot-ic-status').textContent = initialConditions + ? 'Начальные условия загружены из slot-service.' + : 'Начальные условия не заданы.'; + renderSlotCalculationSummary(slotCalculationSummary); + } + + function fillSatelliteForm(satellite, createMode = false, initialConditions = state.selectedSatelliteInitialConditions, slotCalculationSummary = state.selectedSlotCalculationSummary) { + document.getElementById('satellite-id').value = createMode ? '' : satellite.id; + document.getElementById('satellite-id').disabled = !createMode; + document.getElementById('satellite-norad-id').value = satellite.noradId ?? ''; + document.getElementById('satellite-code').value = satellite.code; + document.getElementById('satellite-name').value = satellite.name; + document.getElementById('satellite-type-code').value = satellite.typeCode; + document.getElementById('satellite-active').checked = satellite.active; + document.getElementById('satellite-scan-tle').checked = satellite.scanTle; + document.getElementById('satellite-red').value = satellite.visualization.red; + document.getElementById('satellite-green').value = satellite.visualization.green; + document.getElementById('satellite-blue').value = satellite.visualization.blue; + document.getElementById('satellite-form-title').textContent = createMode ? 'Новый спутник' : `Спутник #${satellite.id}`; + fillProfiles( + satellite, + initialConditions, + slotCalculationSummary + ); + updateColorPreview(); + } + + function newSatelliteFromSelected() { + if (!state.selectedSatellite) { + resetBaseForm(); + return; + } + + state.selectedSatelliteId = null; + state.selectedSatellite = null; + state.selectedSatelliteInitialConditions = null; + state.selectedSlotCalculationSummary = null; + state.createDraftMode = true; + document.getElementById('satellite-id').value = ''; + document.getElementById('satellite-id').disabled = false; + document.getElementById('satellite-form-title').textContent = 'Новый спутник'; + document.getElementById('slot-ic-status').textContent = 'Начальные условия скопированы из выбранного спутника.'; + renderSlotCalculationSummary(null); + renderList(); + } + + async function loadSummaries() { + state.summaries = await requestJson(`${apiBase}/satellites`); + const satelliteIds = state.summaries.map(satellite => satellite.id); + renderList(); + + try { + const slotSummaries = satelliteIds.length + ? await requestJson(`${apiBase}/satellites/slot-calculation-summaries?${buildRepeatedQueryParam('satelliteIds', satelliteIds)}`) + : []; + state.slotSummariesBySatelliteId = new Map( + slotSummaries.map(summary => [Number(summary.satelliteId), summary]) + ); + } catch (error) { + console.warn('Slot calculation summaries are unavailable', error); + state.slotSummariesBySatelliteId = new Map(); + } + renderList(); + } + + async function selectSatellite(id) { + try { + const [satellite, initialConditions] = await Promise.all([ + requestJson(`${apiBase}/satellites/${id}`), + requestJson(`${apiBase}/satellites/${id}/initial-conditions`) + ]); + let slotCalculationSummary = null; + try { + slotCalculationSummary = await requestJson(`${apiBase}/satellites/${id}/slot-calculation-summary`); + } catch (error) { + console.warn(`Slot calculation summary is unavailable for satellite ${id}`, error); + } + state.selectedSatelliteId = id; + state.selectedSatellite = satellite; + state.selectedSatelliteInitialConditions = initialConditions; + state.selectedSlotCalculationSummary = slotCalculationSummary; + state.createDraftMode = false; + state.slotSummariesBySatelliteId.set(Number(id), slotCalculationSummary); + fillSatelliteForm(satellite); + renderList(); + showAlert('satellites-alert', ''); + } catch (error) { + showAlert('satellites-alert', error.message); + } + } + + function basePayload() { + return { + noradId: toNullableNumber(document.getElementById('satellite-norad-id').value), + code: document.getElementById('satellite-code').value.trim(), + name: document.getElementById('satellite-name').value.trim(), + typeCode: document.getElementById('satellite-type-code').value.trim(), + active: document.getElementById('satellite-active').checked, + scanTle: document.getElementById('satellite-scan-tle').checked, + visualization: { + red: Number(document.getElementById('satellite-red').value), + green: Number(document.getElementById('satellite-green').value), + blue: Number(document.getElementById('satellite-blue').value) + } + }; + } + + function observationPayload() { + return { + captureAngle: Number(document.getElementById('observation-capture-angle').value), + sunAngleMin: Number(document.getElementById('observation-sun-angle-min').value), + durationMinSeconds: Number(document.getElementById('observation-duration-min').value), + durationMaxSeconds: Number(document.getElementById('observation-duration-max').value), + mmiSeconds: Number(document.getElementById('observation-mmi').value), + dailyMaxDurationSeconds: Number(document.getElementById('observation-daily-max').value), + revolutionMaxDurationSeconds: Number(document.getElementById('observation-revolution-max').value) + }; + } + + function slotPayload() { + return { + cycleRevs: Number(document.getElementById('slot-cycle-revs').value), + tnCalc: parseDateTimeInput(document.getElementById('slot-tn-calc').value, 'TN calc'), + slotDuration: Number(document.getElementById('slot-duration').value), + durationCalcDays: Number(document.getElementById('slot-duration-calc-days').value), + maxChainLength: Number(document.getElementById('slot-max-chain-length').value), + defaultAngles: parseAngles(document.getElementById('slot-default-angles').value) + }; + } + + function initialConditionsPayload() { + return { + orbPoint: { + time: parseDateTimeInput(document.getElementById('slot-ic-time').value, 'Time', true), + revolution: Number(document.getElementById('slot-ic-revolution').value), + vx: Number(document.getElementById('slot-ic-vx').value), + vy: Number(document.getElementById('slot-ic-vy').value), + vz: Number(document.getElementById('slot-ic-vz').value), + x: Number(document.getElementById('slot-ic-x').value), + y: Number(document.getElementById('slot-ic-y').value), + z: Number(document.getElementById('slot-ic-z').value) + }, + sBall: Number(document.getElementById('slot-ic-s-ball').value), + f81: Number(document.getElementById('slot-ic-f81').value) + }; + } + + document.getElementById('satellites-refresh').addEventListener('click', async () => { + try { + await loadSummaries(); + if (state.selectedSatelliteId) { + await selectSatellite(state.selectedSatelliteId); + } + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('satellites-new').addEventListener('click', () => { + newSatelliteFromSelected(); + showAlert('satellites-alert', ''); + }); + + searchInput.addEventListener('input', renderList); + ['satellite-red', 'satellite-green', 'satellite-blue'].forEach(id => { + document.getElementById(id).addEventListener('input', updateColorPreview); + }); + + document.getElementById('satellite-form').addEventListener('submit', async event => { + event.preventDefault(); + try { + const idField = document.getElementById('satellite-id'); + const isCreate = !state.selectedSatelliteId; + const satelliteId = Number(idField.value); + + const result = isCreate + ? await requestJson(`${apiBase}/satellites`, { + method: 'POST', + body: JSON.stringify({ id: satelliteId, ...basePayload() }) + }) + : await requestJson(`${apiBase}/satellites/${satelliteId}`, { + method: 'PUT', + body: JSON.stringify(basePayload()) + }); + + await loadSummaries(); + if (isCreate && state.createDraftMode) { + state.selectedSatelliteId = result.id; + state.selectedSatellite = result; + state.selectedSatelliteInitialConditions = null; + state.selectedSlotCalculationSummary = null; + document.getElementById('satellite-id').value = result.id; + document.getElementById('satellite-id').disabled = true; + document.getElementById('satellite-form-title').textContent = `Спутник #${result.id}`; + renderList(); + } else { + await selectSatellite(result.id); + } + showAlert('satellites-alert', 'Спутник сохранён', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('satellite-delete').addEventListener('click', async () => { + if (!state.selectedSatelliteId) { + showAlert('satellites-alert', 'Сначала выберите спутник для удаления'); + return; + } + if (!window.confirm(`Удалить спутник #${state.selectedSatelliteId}?`)) { + return; + } + try { + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}`, { method: 'DELETE' }); + await loadSummaries(); + resetBaseForm(); + showAlert('satellites-alert', 'Спутник удалён', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('observation-form').addEventListener('submit', async event => { + event.preventDefault(); + if (!state.selectedSatelliteId) { + showAlert('satellites-alert', 'Сначала сохраните спутник'); + return; + } + try { + const create = !state.selectedSatellite?.observationProfile; + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/observation-profile?create=${create}`, { + method: 'POST', + body: JSON.stringify(observationPayload()) + }); + if (state.createDraftMode) { + state.selectedSatellite = { + ...state.selectedSatellite, + observationProfile: observationPayload() + }; + } else { + await selectSatellite(state.selectedSatelliteId); + } + showAlert('satellites-alert', 'Observation profile сохранён', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('observation-delete').addEventListener('click', async () => { + if (!state.selectedSatelliteId || !state.selectedSatellite?.observationProfile) { + showAlert('satellites-alert', 'Observation profile отсутствует'); + return; + } + try { + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/observation-profile`, { method: 'DELETE' }); + await selectSatellite(state.selectedSatelliteId); + showAlert('satellites-alert', 'Observation profile удалён', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('slot-form').addEventListener('submit', async event => { + event.preventDefault(); + if (!state.selectedSatelliteId) { + showAlert('satellites-alert', 'Сначала сохраните спутник'); + return; + } + try { + const create = !state.selectedSatellite?.slotProfile; + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-profile?create=${create}`, { + method: 'POST', + body: JSON.stringify(slotPayload()) + }); + if (state.createDraftMode) { + state.selectedSatellite = { + ...state.selectedSatellite, + slotProfile: slotPayload() + }; + } else { + await selectSatellite(state.selectedSatelliteId); + } + showAlert('satellites-alert', 'Slot profile сохранён', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('slot-delete').addEventListener('click', async () => { + if (!state.selectedSatelliteId || !state.selectedSatellite?.slotProfile) { + showAlert('satellites-alert', 'Slot profile отсутствует'); + return; + } + try { + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-profile`, { method: 'DELETE' }); + await selectSatellite(state.selectedSatelliteId); + showAlert('satellites-alert', 'Slot profile удалён', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('slot-calculate').addEventListener('click', async () => { + if (!state.selectedSatelliteId) { + showAlert('satellites-alert', 'Сначала сохраните спутник'); + return; + } + try { + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-calculation`, { + method: 'POST', + body: JSON.stringify(slotPayload()) + }); + await loadSummaries(); + await selectSatellite(state.selectedSatelliteId); + showAlert('satellites-alert', 'Расчет слотов запущен', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + document.getElementById('slot-ic-form').addEventListener('submit', async event => { + event.preventDefault(); + if (!state.selectedSatelliteId) { + showAlert('satellites-alert', 'Сначала сохраните спутник'); + return; + } + try { + const create = !state.selectedSatelliteInitialConditions; + await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/initial-conditions?create=${create}`, { + method: 'POST', + body: JSON.stringify(initialConditionsPayload()) + }); + if (state.createDraftMode) { + state.selectedSatelliteInitialConditions = { + satelliteId: state.selectedSatelliteId, + ic: initialConditionsPayload() + }; + document.getElementById('slot-ic-status').textContent = 'Начальные условия сохранены в slot-service.'; + } else { + await selectSatellite(state.selectedSatelliteId); + } + showAlert('satellites-alert', 'Начальные условия сохранены', 'success'); + } catch (error) { + showAlert('satellites-alert', error.message); + } + }); + + loadSummaries() + .then(() => { + updateColorPreview(); + if (state.summaries.length) { + return selectSatellite(state.summaries[0].id); + } + resetBaseForm(); + return null; + }) + .catch(error => showAlert('satellites-alert', error.message)); + } + + if (page === 'groups') { + initGroupsPage(); + } + + if (page === 'satellites') { + initSatellitesPage(); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/cesium_scripts.js b/services/pcp-ui-service/src/main/resources/static/cesium_scripts.js new file mode 100644 index 0000000..79d4fb8 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/cesium_scripts.js @@ -0,0 +1,654 @@ + + var MNTH = ["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"]; + var pcpCesiumToolsState = null; + /** преобразование целого числа к строке заданного формата + s - число (25) + count - размер строки (4) + результат - строка (0025) + */ + function STRTOFORMAT(s, count) + { + var s2 = String(s); + var len = s2.length; + if (len < count) + { + for (iSTRTOFORMAT=0; iSTRTOFORMAT= 0 ? positiveSuffix : negativeSuffix; + return Math.abs(value).toFixed(6) + '° ' + suffix; + } + + function formatCursorCoordinates(viewer, cartesian) { + if (!viewer || !Cesium.defined(cartesian)) { + return 'Координаты: -'; + } + + var ellipsoid = viewer.scene.globe.ellipsoid; + var cartographic = ellipsoid.cartesianToCartographic(cartesian); + if (!Cesium.defined(cartographic)) { + return 'Координаты: -'; + } + + var lon = Cesium.Math.toDegrees(cartographic.longitude); + var lat = Cesium.Math.toDegrees(cartographic.latitude); + return 'Lon: ' + formatDegree(lon, 'E', 'W') + ' | Lat: ' + formatDegree(lat, 'N', 'S'); + } + + function setRulerInfoText(text) { + return; + } + + function getRulerDisplayPositions(ruler) { + var positions = []; + if (!ruler || !ruler.points) { + return positions; + } + + for (var i = 0; i < ruler.points.length; i++) { + positions.push(ruler.points[i]); + } + + if (ruler.points.length > 0 && ruler.tempPoint) { + var lastFixed = ruler.points[ruler.points.length - 1]; + if (!Cesium.Cartesian3.equalsEpsilon(lastFixed, ruler.tempPoint, Cesium.Math.EPSILON7, Cesium.Math.EPSILON7)) { + positions.push(ruler.tempPoint); + } + } + + return positions; + } + + function computePolylineDistanceMeters(viewer, positions) { + if (!viewer || !positions || positions.length < 2) { + return 0; + } + + var total = 0; + for (var i = 1; i < positions.length; i++) { + total += computeSurfaceDistanceMeters(viewer, positions[i - 1], positions[i]); + } + return total; + } + + function addRulerVertexMarker(viewer, position, isFirstPoint) { + return viewer.entities.add({ + name: 'pcp-ruler-vertex', + position: position, + point: { + pixelSize: 8, + color: isFirstPoint ? Cesium.Color.LIME : Cesium.Color.RED, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + }); + } + + function updateCursorCoordinatesText(viewer, screenPosition) { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsPanel) { + return; + } + + if (!pcpCesiumToolsState.cursorCoordsEnabled) { + pcpCesiumToolsState.cursorCoordsPanel.style.display = 'none'; + return; + } + + var point = getMapCartesianPosition(viewer, screenPosition); + pcpCesiumToolsState.cursorCoordsPanel.textContent = formatCursorCoordinates(viewer, point); + } + + function updateButtonActiveState(button, active) { + if (!button) { + return; + } + + if (active) { + button.classList.add('active'); + button.style.background = '#0d6efd'; + button.style.borderColor = '#0d6efd'; + button.style.color = '#ffffff'; + } else { + button.classList.remove('active'); + button.style.background = ''; + button.style.borderColor = ''; + button.style.color = ''; + } + } + + function clearRulerMeasurement(viewer) { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) { + return; + } + + var ruler = pcpCesiumToolsState.ruler; + if (ruler.lineEntity) { + viewer.entities.remove(ruler.lineEntity); + ruler.lineEntity = null; + } + if (ruler.labelEntity) { + viewer.entities.remove(ruler.labelEntity); + ruler.labelEntity = null; + } + if (ruler.pointEntities && ruler.pointEntities.length) { + for (var i = 0; i < ruler.pointEntities.length; i++) { + viewer.entities.remove(ruler.pointEntities[i]); + } + } + + ruler.pointEntities = []; + ruler.points = []; + ruler.tempPoint = null; + ruler.completed = false; + } + + function ensureRulerEntities(viewer) { + var ruler = pcpCesiumToolsState.ruler; + if (ruler.lineEntity) { + return; + } + + ruler.lineEntity = viewer.entities.add({ + name: 'pcp-ruler-line', + polyline: { + positions: new Cesium.CallbackProperty(function () { + return getRulerDisplayPositions(ruler); + }, false), + width: 3, + material: Cesium.Color.CYAN + } + }); + + ruler.labelEntity = viewer.entities.add({ + name: 'pcp-ruler-label', + position: new Cesium.CallbackProperty(function () { + var positions = getRulerDisplayPositions(ruler); + if (!positions.length) { + return Cesium.Cartesian3.ZERO; + } + return positions[positions.length - 1]; + }, false), + label: { + text: new Cesium.CallbackProperty(function () { + var positions = getRulerDisplayPositions(ruler); + if (positions.length < 2) { + return ''; + } + var distance = computePolylineDistanceMeters(viewer, positions); + return 'Σ ' + formatDistanceMeters(distance); + }, false), + showBackground: true, + backgroundColor: Cesium.Color.BLACK.withAlpha(0.75), + fillColor: Cesium.Color.WHITE, + font: '14px sans-serif', + pixelOffset: new Cesium.Cartesian2(10, -10), + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + }); + } + + function updateRulerDistanceInfo(viewer) { + return; + } + + function startRulerMeasurement(viewer, point) { + var ruler = pcpCesiumToolsState.ruler; + + clearRulerMeasurement(viewer); + ensureRulerEntities(viewer); + + ruler.points = [Cesium.Cartesian3.clone(point)]; + ruler.tempPoint = Cesium.Cartesian3.clone(point); + ruler.completed = false; + ruler.pointEntities = [addRulerVertexMarker(viewer, ruler.points[0], true)]; + + updateRulerDistanceInfo(viewer); + } + + function completeRulerMeasurement(viewer, point) { + var ruler = pcpCesiumToolsState.ruler; + + var fixedPoint = Cesium.Cartesian3.clone(point); + ruler.points.push(fixedPoint); + ruler.tempPoint = Cesium.Cartesian3.clone(point); + ruler.completed = false; + if (!ruler.pointEntities) { + ruler.pointEntities = []; + } + ruler.pointEntities.push(addRulerVertexMarker(viewer, fixedPoint, false)); + + updateRulerDistanceInfo(viewer); + } + + function disableRuler(viewer) { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) { + return; + } + + var ruler = pcpCesiumToolsState.ruler; + ruler.enabled = false; + + if (ruler.handler) { + ruler.handler.destroy(); + ruler.handler = null; + } + + clearRulerMeasurement(viewer); + updateButtonActiveState(pcpCesiumToolsState.rulerButton, false); + viewer.container.style.cursor = ''; + viewer.scene.requestRender(); + } + + function enableRuler(viewer) { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) { + return; + } + + var ruler = pcpCesiumToolsState.ruler; + if (ruler.enabled) { + return; + } + + ruler.enabled = true; + clearRulerMeasurement(viewer); + + ruler.handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + ruler.handler.setInputAction(function (movement) { + var point = getMapCartesianPosition(viewer, movement.position); + if (!point) { + return; + } + + if (!ruler.points || ruler.points.length === 0) { + startRulerMeasurement(viewer, point); + return; + } + + completeRulerMeasurement(viewer, point); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + ruler.handler.setInputAction(function (movement) { + if (!ruler.points || ruler.points.length === 0) { + return; + } + + var point = getMapCartesianPosition(viewer, movement.endPosition); + if (!point) { + return; + } + + ruler.tempPoint = Cesium.Cartesian3.clone(point); + updateRulerDistanceInfo(viewer); + viewer.scene.requestRender(); + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + + ruler.handler.setInputAction(function () { + clearRulerMeasurement(viewer); + updateRulerDistanceInfo(viewer); + viewer.scene.requestRender(); + }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); + + updateButtonActiveState(pcpCesiumToolsState.rulerButton, true); + viewer.container.style.cursor = 'crosshair'; + viewer.scene.requestRender(); + } + + function toggleRuler(viewer) { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) { + return; + } + + if (pcpCesiumToolsState.ruler.enabled) { + disableRuler(viewer); + } else { + enableRuler(viewer); + } + } + + function setSunLightingEnabled(viewer, enabled) { + if (!pcpCesiumToolsState) { + return; + } + + pcpCesiumToolsState.sunLightingEnabled = enabled; + viewer.scene.globe.enableLighting = enabled; + viewer.scene.globe.dynamicAtmosphereLighting = enabled; + viewer.scene.globe.dynamicAtmosphereLightingFromSun = enabled; + + updateButtonActiveState(pcpCesiumToolsState.sunButton, enabled); + if (pcpCesiumToolsState.sunButton) { + pcpCesiumToolsState.sunButton.title = enabled + ? 'Освещенность Солнцем: включена' + : 'Освещенность Солнцем: выключена'; + } + + viewer.scene.requestRender(); + } + + function toggleSunLighting(viewer) { + if (!pcpCesiumToolsState) { + return; + } + + setSunLightingEnabled(viewer, !pcpCesiumToolsState.sunLightingEnabled); + } + + function setCursorCoordinatesEnabled(viewer, enabled) { + if (!pcpCesiumToolsState) { + return; + } + + pcpCesiumToolsState.cursorCoordsEnabled = enabled; + updateButtonActiveState(pcpCesiumToolsState.cursorCoordsButton, enabled); + + if (pcpCesiumToolsState.cursorCoordsPanel) { + pcpCesiumToolsState.cursorCoordsPanel.style.display = enabled ? 'block' : 'none'; + pcpCesiumToolsState.cursorCoordsPanel.textContent = enabled + ? 'Координаты: наведите курсор на карту' + : ''; + } + + viewer.scene.requestRender(); + } + + function toggleCursorCoordinates(viewer) { + if (!pcpCesiumToolsState) { + return; + } + + setCursorCoordinatesEnabled(viewer, !pcpCesiumToolsState.cursorCoordsEnabled); + } + + function createCesiumToolsWidget(viewer) { + if (pcpCesiumToolsState && pcpCesiumToolsState.toolbarEl) { + return; + } + + var viewerToolbar = viewer.container.querySelector('.cesium-viewer-toolbar'); + if (!viewerToolbar) { + return; + } + + var rulerButton = document.createElement('button'); + rulerButton.type = 'button'; + rulerButton.className = 'cesium-button cesium-toolbar-button'; + rulerButton.title = 'Включить/выключить линейку'; + rulerButton.style.marginLeft = '4px'; + rulerButton.style.width = '32px'; + rulerButton.style.height = '32px'; + rulerButton.style.display = 'inline-flex'; + rulerButton.style.alignItems = 'center'; + rulerButton.style.justifyContent = 'center'; + rulerButton.innerHTML = ''; + + var coordsButton = document.createElement('button'); + coordsButton.type = 'button'; + coordsButton.className = 'cesium-button cesium-toolbar-button'; + coordsButton.title = 'Включить/выключить отображение координат под курсором'; + coordsButton.style.marginLeft = '4px'; + coordsButton.style.width = '32px'; + coordsButton.style.height = '32px'; + coordsButton.style.display = 'inline-flex'; + coordsButton.style.alignItems = 'center'; + coordsButton.style.justifyContent = 'center'; + coordsButton.innerHTML = 'Координаты'; + + var sunButton = document.createElement('button'); + sunButton.type = 'button'; + sunButton.className = 'cesium-button cesium-toolbar-button'; + sunButton.title = 'Включить/выключить освещенность Солнцем'; + sunButton.style.marginLeft = '4px'; + sunButton.style.width = '32px'; + sunButton.style.height = '32px'; + sunButton.style.display = 'inline-flex'; + sunButton.style.alignItems = 'center'; + sunButton.style.justifyContent = 'center'; + sunButton.innerHTML = ''; + + var cursorCoordsPanel = document.createElement('div'); + cursorCoordsPanel.className = 'pcp-cursor-coords-panel'; + cursorCoordsPanel.style.position = 'absolute'; + cursorCoordsPanel.style.top = '50px'; + cursorCoordsPanel.style.right = '10px'; + cursorCoordsPanel.style.zIndex = '1000'; + cursorCoordsPanel.style.maxWidth = '520px'; + cursorCoordsPanel.style.padding = '6px 10px'; + cursorCoordsPanel.style.background = 'rgba(0, 0, 0, 0.75)'; + cursorCoordsPanel.style.color = '#ffffff'; + cursorCoordsPanel.style.borderRadius = '4px'; + cursorCoordsPanel.style.fontSize = '12px'; + cursorCoordsPanel.style.lineHeight = '1.3'; + cursorCoordsPanel.style.display = 'none'; + cursorCoordsPanel.style.pointerEvents = 'none'; + cursorCoordsPanel.textContent = ''; + + viewerToolbar.appendChild(rulerButton); + viewerToolbar.appendChild(coordsButton); + viewerToolbar.appendChild(sunButton); + viewer.cesiumWidget.container.appendChild(cursorCoordsPanel); + + pcpCesiumToolsState = { + toolbarEl: viewerToolbar, + rulerButton: rulerButton, + cursorCoordsButton: coordsButton, + cursorCoordsPanel: cursorCoordsPanel, + sunButton: sunButton, + cursorCoordsEnabled: false, + sunLightingEnabled: false, + ruler: { + enabled: false, + handler: null, + points: [], + tempPoint: null, + completed: false, + lineEntity: null, + labelEntity: null, + pointEntities: [] + } + }; + + rulerButton.addEventListener('click', function () { + toggleRuler(viewer); + }); + + coordsButton.addEventListener('click', function () { + toggleCursorCoordinates(viewer); + }); + + sunButton.addEventListener('click', function () { + toggleSunLighting(viewer); + }); + + viewer.scene.canvas.addEventListener('mousemove', function (event) { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsEnabled) { + return; + } + + var rect = viewer.scene.canvas.getBoundingClientRect(); + var screenPosition = new Cesium.Cartesian2( + event.clientX - rect.left, + event.clientY - rect.top + ); + updateCursorCoordinatesText(viewer, screenPosition); + }); + + viewer.scene.canvas.addEventListener('mouseleave', function () { + if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsEnabled || !pcpCesiumToolsState.cursorCoordsPanel) { + return; + } + pcpCesiumToolsState.cursorCoordsPanel.textContent = 'Координаты: курсор вне карты'; + }); + } + + function initializeCesium() { + // Задаем область по умолчанию + var ext = Cesium.Rectangle.fromDegrees(351, 72.6, 76.3, 31.4); + Cesium.Camera.DEFAULT_VIEW_RECTANGLE = ext; + var imageryProviderViewModels = Cesium.createDefaultImageryProviderViewModels(); + var arcGisWorldImagery = imageryProviderViewModels.find(function (viewModel) { + return viewModel && viewModel.name && viewModel.name.indexOf('ArcGIS World Imagery') !== -1; + }) || imageryProviderViewModels[0]; + + // Создаем viewer + var viewer = new Cesium.Viewer('cesiumContainer', { + baseLayerPicker: true, + imageryProviderViewModels: imageryProviderViewModels, + selectedImageryProviderViewModel: arcGisWorldImagery, + geocoder: false, + infoBox: true, + navigationHelpButton: false, + sceneMode: Cesium.SceneMode.SCENE2D, + // Оптимизация для полного заполнения контейнера + useDefaultRenderLoop: true, + orderIndependentTranslucency: false, + contextOptions: { + webgl: { + alpha: false, + antialias: true, + preserveDrawingBuffer: true, + failIfMajorPerformanceCaveat: false + } + } + }); + + var scene = viewer.scene; + + + // Настройка форматирования времени + if (viewer.animation && viewer.animation.viewModel) { + viewer.animation.viewModel.timeFormatter = TDMT; + viewer.animation.viewModel.dateFormatter = TDMD; + } + + if (viewer.timeline) { + viewer.timeline.makeLabel = TDMDT; + } + + // Обработчик изменения размера окна + window.addEventListener('resize', function() { + viewer.resize(); + }); + + // Принудительно вызываем resize после создания viewer + setTimeout(function() { + viewer.resize(); + }, 100); + + createCesiumToolsWidget(viewer); + setSunLightingEnabled(viewer, false); + + // Сохраняем viewer в глобальной области видимости для доступа из других функций + window.viewer = viewer; + window.heatmapLayer = null; // Здесь будет храниться слой тепловой карты + } diff --git a/services/pcp-ui-service/src/main/resources/static/complex_plan_scripts.js b/services/pcp-ui-service/src/main/resources/static/complex_plan_scripts.js new file mode 100644 index 0000000..7493947 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/complex_plan_scripts.js @@ -0,0 +1,752 @@ +(function () { + const apiBase = '/api/com-plan/runs'; + const state = { + runs: [], + selectedRunId: null, + selectedRun: null, + selectedRunModes: null, + selectedRunModesError: null, + page: 1, + pageSize: 10 + }; + + function text(value, fallback = '') { + if (value === null || value === undefined || value === '') { + return fallback; + } + return String(value); + } + + function escapeHtml(value) { + return text(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function showAlert(message, type = 'danger') { + const container = document.getElementById('complex-plan-alert'); + container.innerHTML = message + ? `` + : ''; + } + + function formatDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString('ru-RU'); + } + + function formatDuration(value) { + if (value === null || value === undefined) return '-'; + const ms = Number(value); + if (!Number.isFinite(ms)) return text(value, '-'); + const seconds = Math.round(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return minutes > 0 ? `${minutes} мин ${remainder} с` : `${remainder} с`; + } + + function formatList(values) { + return Array.isArray(values) && values.length ? values.join(', ') : '-'; + } + + function formatNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) return '-'; + return number.toLocaleString('ru-RU', { maximumFractionDigits: 4 }); + } + + function formatPercent(value) { + const number = Number(value); + if (!Number.isFinite(number)) return '-'; + return `${number.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}%`; + } + + function isoDateTime(value) { + if (!value) return ''; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toISOString(); + } + + function formatDateTimeLocal(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value).slice(0, 16); + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } + + async function requestJson(url, options) { + const response = await fetch(url, options); + if (response.status === 204) { + return null; + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const body = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (body && typeof body === 'object') { + throw new Error(body.error || body.message || Object.values(body).join('; ')); + } + throw new Error(body || `HTTP ${response.status}`); + } + + return body; + } + + function renderRuns() { + const body = document.getElementById('complex-plan-runs-body'); + const meta = document.getElementById('complex-plan-list-meta'); + const pagination = document.getElementById('complex-plan-pagination'); + + if (!state.runs.length) { + body.innerHTML = 'Расчеты не найдены'; + meta.textContent = 'Нет расчетов'; + pagination.innerHTML = ''; + return; + } + + const totalPages = Math.max(1, Math.ceil(state.runs.length / state.pageSize)); + state.page = Math.min(Math.max(1, state.page), totalPages); + const pageStart = (state.page - 1) * state.pageSize; + const pageRuns = state.runs.slice(pageStart, pageStart + state.pageSize); + + meta.textContent = `Всего расчетов: ${state.runs.length}`; + body.innerHTML = pageRuns.map(run => { + const runId = run.runId ?? run.id; + const activeClass = String(runId) === String(state.selectedRunId) ? ' active' : ''; + const interval = `${formatDateTime(run.intervalStart)} - ${formatDateTime(run.intervalEnd)}`; + return ` + + ${escapeHtml(runId)} + ${escapeHtml(run.status)} + ${escapeHtml(interval)} + ${escapeHtml(formatList(run.satelliteIds))} + + + + + `; + }).join(''); + + body.querySelectorAll('.complex-plan-run-row').forEach(row => { + row.addEventListener('click', () => selectRun(row.dataset.runId)); + }); + body.querySelectorAll('[data-delete-run-id]').forEach(button => { + button.addEventListener('click', event => { + event.stopPropagation(); + deleteRun(button.dataset.deleteRunId); + }); + }); + + pagination.innerHTML = ` + + Страница ${state.page} из ${totalPages} + + `; + document.getElementById('complex-plan-page-prev')?.addEventListener('click', () => { + state.page -= 1; + renderRuns(); + }); + document.getElementById('complex-plan-page-next')?.addEventListener('click', () => { + state.page += 1; + renderRuns(); + }); + } + + function field(label, value) { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `; + } + + function isSurveyMode(mode) { + return !mode.type || String(mode.type).toUpperCase() === 'SURVEY'; + } + + function isAddedSurveyMode(mode) { + const source = String(mode.source || '').toUpperCase(); + return isSurveyMode(mode) && (source === 'COMPLAN' || source === 'MIXED'); + } + + function addedSurveyRowsBySatellite(run, modes) { + const countsBySatelliteId = new Map(); + modes + .filter(isAddedSurveyMode) + .forEach(mode => { + if (mode.satelliteId === null || mode.satelliteId === undefined || mode.satelliteId === '') { + return; + } + + const key = String(mode.satelliteId); + countsBySatelliteId.set(key, (countsBySatelliteId.get(key) || 0) + 1); + }); + + const satelliteIds = [...new Set([ + ...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []), + ...modes.map(mode => mode.satelliteId) + ].filter(id => id !== null && id !== undefined && id !== ''))] + .sort((left, right) => Number(left) - Number(right)); + + return satelliteIds.map(satelliteId => [ + satelliteId, + countsBySatelliteId.get(String(satelliteId)) || 0 + ]); + } + + function renderAddedSurveyStats(run) { + if (state.selectedRunModesError) { + return '
Не удалось загрузить съемки расчета
'; + } + + if (state.selectedRunModes === null) { + return '
Загрузка съемок...
'; + } + + const rows = addedSurveyRowsBySatellite(run, state.selectedRunModes); + if (!rows.length) { + return '
Нет данных по спутникам
'; + } + + return ` +
+ + + + + + + + + ${rows.map(([satelliteId, count]) => ` + + + + + `).join('')} + +
СпутникДобавлено съемок
${escapeHtml(satelliteId)}${escapeHtml(count)}
+
+ `; + } + + function renderDetail(run) { + const container = document.getElementById('complex-plan-detail'); + const meta = document.getElementById('complex-plan-detail-meta'); + + if (!run) { + meta.textContent = 'Выберите расчет слева.'; + container.innerHTML = '
Нет выбранного расчета
'; + document.getElementById('complex-plan-report').disabled = true; + return; + } + + const runId = run.runId ?? run.id; + meta.textContent = `Расчет #${runId}`; + document.getElementById('complex-plan-report').disabled = false; + const requestPayload = formatJsonText(run.requestPayload); + + container.innerHTML = ` +
+ ${field('ID', runId)} + ${field('Статус', run.status)} + ${field('Начало интервала', formatDateTime(run.intervalStart))} + ${field('Конец интервала', formatDateTime(run.intervalEnd))} + ${field('Спутники', formatList(run.satelliteIds))} + ${field('Инициатор', text(run.requestedBy, '-'))} + ${field('Создан', formatDateTime(run.createdAt))} + ${field('Запущен', formatDateTime(run.startedAt))} + ${field('Завершен', formatDateTime(run.finishedAt))} + ${field('Длительность', formatDuration(run.durationMs))} + ${field('Ошибка', text(run.errorMessage, '-'))} + ${field('Snapshot IDs', formatList(run.snapshotIds))} + ${field('Calculated modes', run.calculatedModesCount ?? 0)} + ${field('Booked modes', run.bookedModesCount ?? 0)} + ${field('Complan modes', run.complanModesCount ?? 0)} + ${field('Mixed modes', run.mixedModesCount ?? 0)} + ${field('Affected satellites', run.affectedSatellitesCount ?? 0)} + ${field('Processed cells', run.processedCellsCount ?? 0)} + ${field('Booked slot links', run.bookedSlotLinksCount ?? 0)} + ${field('Процент покрытия', formatPercent(run.coveredAreaPercent))} +
+
Добавлено съемок по спутникам
+
${renderAddedSurveyStats(run)}
+
Request payload
+
${escapeHtml(requestPayload || '-')}
+ `; + } + + function formatJsonText(value) { + if (!value) return ''; + if (typeof value === 'object') { + return JSON.stringify(value, null, 2); + } + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch (error) { + return String(value); + } + } + + function parseRequestPayload(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + + try { + return JSON.parse(value); + } catch (error) { + return {}; + } + } + + function fillProcessForm(run) { + const payload = parseRequestPayload(run.requestPayload); + const intervalStart = payload.intervalStart || run.intervalStart; + const intervalEnd = payload.intervalEnd || run.intervalEnd; + const satelliteIds = Array.isArray(payload.satelliteIds) && payload.satelliteIds.length + ? payload.satelliteIds + : (Array.isArray(run.satelliteIds) ? run.satelliteIds : []); + + document.getElementById('complex-plan-interval-start').value = formatDateTimeLocal(intervalStart); + document.getElementById('complex-plan-interval-end').value = formatDateTimeLocal(intervalEnd); + document.getElementById('complex-plan-satellite-ids').value = satelliteIds.join(', '); + document.getElementById('complex-plan-sun').value = payload.sun === null || payload.sun === undefined + ? '' + : payload.sun; + document.getElementById('complex-plan-count-lat').value = payload.countLat === null || payload.countLat === undefined + ? '' + : payload.countLat; + document.getElementById('complex-plan-count-long').value = payload.countLong === null || payload.countLong === undefined + ? '' + : payload.countLong; + + const coverageSourceInput = document.getElementById('complex-plan-coverage-source'); + if (payload.coverageSource && Array.from(coverageSourceInput.options).some(option => option.value === payload.coverageSource)) { + coverageSourceInput.value = payload.coverageSource; + } else { + coverageSourceInput.value = 'SLOTS'; + } + + const coverageStrategyInput = document.getElementById('complex-plan-coverage-strategy'); + if (payload.coverageStrategy && Array.from(coverageStrategyInput.options).some(option => option.value === payload.coverageStrategy)) { + coverageStrategyInput.value = payload.coverageStrategy; + } else { + coverageStrategyInput.value = 'GREEDY'; + } + } + + async function selectRun(runId) { + if (!runId) return; + + state.selectedRunId = runId; + state.selectedRunModes = null; + state.selectedRunModesError = null; + renderRuns(); + renderDetail(null); + document.getElementById('complex-plan-detail-meta').textContent = `Загрузка расчета #${runId}...`; + + try { + const run = await requestJson(`${apiBase}/${encodeURIComponent(runId)}`); + state.selectedRun = run; + renderDetail(run); + fillProcessForm(run); + showAlert(''); + + try { + const modes = await requestJson(`${apiBase}/${encodeURIComponent(runId)}/modes`); + if (String(state.selectedRunId) !== String(runId)) { + return; + } + + state.selectedRunModes = Array.isArray(modes) ? modes : []; + state.selectedRunModesError = null; + } catch (error) { + if (String(state.selectedRunId) !== String(runId)) { + return; + } + + state.selectedRunModes = []; + state.selectedRunModesError = error; + } + renderDetail(run); + } catch (error) { + state.selectedRun = null; + state.selectedRunModes = null; + state.selectedRunModesError = null; + showAlert(error.message || 'Не удалось загрузить расчет'); + } + } + + async function deleteRun(runId) { + if (!runId) return; + const confirmed = window.confirm(`Удалить версию расчета #${runId}?`); + if (!confirmed) return; + + try { + await requestJson(`${apiBase}/${encodeURIComponent(runId)}`, { method: 'DELETE' }); + if (String(state.selectedRunId) === String(runId)) { + state.selectedRunId = null; + state.selectedRun = null; + state.selectedRunModes = null; + state.selectedRunModesError = null; + renderDetail(null); + } + await loadRuns(); + showAlert(`Версия расчета #${runId} удалена`, 'success'); + } catch (error) { + showAlert(error.message || 'Не удалось удалить расчет'); + } + } + + function parseSatelliteIds(value) { + return value + .split(/[,\s;]+/) + .map(item => item.trim()) + .filter(Boolean) + .map(item => Number(item)) + .filter(item => Number.isFinite(item)); + } + + function parseOptionalPositiveInt(value) { + const parsedValue = Number.parseInt(String(value || '').trim(), 10); + return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : null; + } + + async function submitProcessForm(event) { + event.preventDefault(); + const submitButton = document.getElementById('complex-plan-process-submit'); + const intervalStart = document.getElementById('complex-plan-interval-start').value; + const intervalEnd = document.getElementById('complex-plan-interval-end').value; + const satelliteIds = parseSatelliteIds(document.getElementById('complex-plan-satellite-ids').value); + const sunValue = document.getElementById('complex-plan-sun').value.trim(); + const coverageSource = document.getElementById('complex-plan-coverage-source').value; + const coverageStrategy = document.getElementById('complex-plan-coverage-strategy').value; + const countLat = parseOptionalPositiveInt(document.getElementById('complex-plan-count-lat').value); + const countLong = parseOptionalPositiveInt(document.getElementById('complex-plan-count-long').value); + + if (!intervalStart || !intervalEnd || satelliteIds.length === 0) { + showAlert('Заполните интервал и список Satellite IDs'); + return; + } + + submitButton.disabled = true; + showAlert(''); + try { + const response = await requestJson('/api/com-plan/process', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + intervalStart: intervalStart, + intervalEnd: intervalEnd, + satelliteId: null, + satelliteIds: satelliteIds, + sun: sunValue ? Number(sunValue) : null, + coverageSource: coverageSource || null, + countLat: countLat, + countLong: countLong, + coverageStrategy: coverageStrategy || null + }) + }); + const runId = response?.runId ?? response?.id; + await loadRuns(runId ? String(runId) : null); + showAlert(`Расчет${runId ? ` #${runId}` : ''} запущен`, 'success'); + } catch (error) { + showAlert(error.message || 'Не удалось запустить расчет'); + } finally { + submitButton.disabled = false; + } + } + + async function loadRuns(preferredRunId) { + const refreshButton = document.getElementById('complex-plan-refresh'); + refreshButton.disabled = true; + if (!preferredRunId) { + showAlert(''); + } + document.getElementById('complex-plan-list-meta').textContent = 'Загрузка...'; + + try { + const runs = await requestJson(apiBase); + state.runs = Array.isArray(runs) ? runs : []; + const preferredId = preferredRunId || state.selectedRunId; + const preferredRun = state.runs.find(run => String(run.runId ?? run.id) === String(preferredId)); + state.selectedRunId = preferredRun + ? String(preferredRun.runId ?? preferredRun.id) + : (state.runs[0] ? String(state.runs[0].runId ?? state.runs[0].id) : null); + if (state.selectedRunId) { + const selectedIndex = state.runs.findIndex(run => String(run.runId ?? run.id) === String(state.selectedRunId)); + state.page = Math.floor(Math.max(0, selectedIndex) / state.pageSize) + 1; + } + renderRuns(); + if (state.selectedRunId) { + await selectRun(state.selectedRunId); + } else { + state.selectedRun = null; + renderDetail(null); + } + } catch (error) { + state.runs = []; + renderRuns(); + state.selectedRunModes = null; + state.selectedRunModesError = null; + renderDetail(null); + showAlert(error.message || 'Не удалось загрузить расчеты'); + } finally { + refreshButton.disabled = false; + } + } + + function csvValue(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value).replace('.', ','); + } + + const normalized = text(value, '') + .replace(/\r?\n/g, ' ') + .replace(/"/g, '""'); + return `"${normalized}"`; + } + + function csvRow(values) { + return values.map(csvValue).join(';'); + } + + function runDetailRows(run) { + return [ + ['ID', run.runId ?? run.id], + ['Статус', run.status], + ['Начало интервала', isoDateTime(run.intervalStart)], + ['Конец интервала', isoDateTime(run.intervalEnd)], + ['Спутники', formatList(run.satelliteIds)], + ['Инициатор', text(run.requestedBy, '-')], + ['Создан', isoDateTime(run.createdAt)], + ['Запущен', isoDateTime(run.startedAt)], + ['Завершен', isoDateTime(run.finishedAt)], + ['Длительность, мс', run.durationMs ?? ''], + ['Ошибка', text(run.errorMessage, '-')], + ['Snapshot IDs', formatList(run.snapshotIds)], + ['Calculated modes', run.calculatedModesCount ?? 0], + ['Booked modes', run.bookedModesCount ?? 0], + ['Complan modes', run.complanModesCount ?? 0], + ['Mixed modes', run.mixedModesCount ?? 0], + ['Affected satellites', run.affectedSatellitesCount ?? 0], + ['Processed cells', run.processedCellsCount ?? 0], + ['Booked slot links', run.bookedSlotLinksCount ?? 0], + ['Процент покрытия', Number(run.coveredAreaPercent) || 0] + ]; + } + + function parseDate(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + function intervalDurationDays(run) { + const intervalStart = parseDate(run.intervalStart); + const intervalEnd = parseDate(run.intervalEnd); + if (!intervalStart || !intervalEnd || intervalEnd <= intervalStart) { + return 0; + } + return (intervalEnd.getTime() - intervalStart.getTime()) / 86400000; + } + + function modeDurationSeconds(mode) { + const duration = Number(mode.duration); + if (Number.isFinite(duration) && duration > 0) { + return duration; + } + + const startTime = parseDate(mode.startTime); + const endTime = parseDate(mode.endTime); + if (!startTime || !endTime || endTime <= startTime) { + return 0; + } + return (endTime.getTime() - startTime.getTime()) / 1000; + } + + async function loadSatelliteDetails(satelliteIds) { + const uniqueIds = [...new Set(satelliteIds.filter(id => id !== null && id !== undefined && id !== ''))]; + const entries = await Promise.all(uniqueIds.map(async satelliteId => { + try { + const satellite = await requestJson(`/api/catalog/satellites/${encodeURIComponent(satelliteId)}`); + return [String(satelliteId), satellite]; + } catch (error) { + return [String(satelliteId), null]; + } + })); + return new Map(entries); + } + + function satelliteLoadRows(run, modes, satellitesById) { + const intervalDays = intervalDurationDays(run); + const satelliteIds = [...new Set([ + ...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []), + ...modes.map(mode => mode.satelliteId) + ].filter(id => id !== null && id !== undefined && id !== ''))] + .sort((left, right) => Number(left) - Number(right)); + + return satelliteIds.map(satelliteId => { + const satellite = satellitesById.get(String(satelliteId)); + const dailyMaxDurationSeconds = Number(satellite?.observationProfile?.dailyMaxDurationSeconds); + const totalSurveySeconds = modes + .filter(mode => String(mode.satelliteId) === String(satelliteId)) + .filter(isSurveyMode) + .reduce((sum, mode) => sum + modeDurationSeconds(mode), 0); + const capacitySeconds = Number.isFinite(dailyMaxDurationSeconds) && dailyMaxDurationSeconds > 0 + ? intervalDays * dailyMaxDurationSeconds + : 0; + const loadFactor = capacitySeconds > 0 + ? (totalSurveySeconds / capacitySeconds) * 100 + : null; + + return [ + Number(satelliteId), + satellite?.name ?? '', + intervalDays, + Number.isFinite(dailyMaxDurationSeconds) ? dailyMaxDurationSeconds / 86400 : '', + Number.isFinite(dailyMaxDurationSeconds) ? dailyMaxDurationSeconds : '', + capacitySeconds, + totalSurveySeconds, + loadFactor === null ? '' : loadFactor + ]; + }); + } + + function modeRows(modes) { + return modes + .slice() + .sort((left, right) => String(left.startTime || '').localeCompare(String(right.startTime || ''))) + .map(mode => [ + mode.id ?? '', + mode.satelliteId ?? '', + mode.source ?? '', + mode.type ?? '', + isoDateTime(mode.startTime), + isoDateTime(mode.endTime), + mode.revolution ?? '', + mode.roll ?? '', + mode.lat ?? '', + mode.longitude ?? '', + mode.duration ?? '', + Array.isArray(mode.cellNums) ? mode.cellNums.join(', ') : text(mode.cellNum, ''), + Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds.join(', ') : '', + mode.snapshotId ?? '', + mode.contourWkt ?? '' + ]); + } + + function downloadCsv(filename, rows) { + const csv = '\uFEFF' + rows.map(csvRow).join('\n'); + const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' })); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + async function downloadReport() { + const run = state.selectedRun; + const runId = run?.runId ?? run?.id; + if (!runId) { + showAlert('Выберите расчет для формирования отчета'); + return; + } + + const reportButton = document.getElementById('complex-plan-report'); + reportButton.disabled = true; + try { + const modes = await requestJson(`${apiBase}/${encodeURIComponent(runId)}/modes`); + const normalizedModes = Array.isArray(modes) ? modes : []; + const satelliteIds = [ + ...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []), + ...normalizedModes.map(mode => mode.satelliteId) + ]; + const satellitesById = await loadSatelliteDetails(satelliteIds); + const rows = [ + ['Детальная информация о плане'], + ['Параметр', 'Значение'], + ...runDetailRows(run), + [], + ['Добавлено съемок по спутникам'], + ['satelliteId', 'addedSurveyModesCount'], + ...addedSurveyRowsBySatellite(run, normalizedModes), + [], + ['Сводная таблица по загруженности спутников'], + [ + 'satelliteId', + 'satelliteName', + 'calculationDurationDays', + 'dailyMaxDurationDays', + 'dailyMaxDurationSeconds', + 'availableDurationSeconds', + 'surveyModesDurationSeconds', + 'loadFactor' + ], + ...satelliteLoadRows(run, normalizedModes, satellitesById), + [], + ['Режимы спутников'], + [ + 'modeId', + 'satelliteId', + 'source', + 'type', + 'startTime', + 'endTime', + 'revolution', + 'roll', + 'lat', + 'longitude', + 'duration', + 'cellNums', + 'bookedSlotIds', + 'snapshotId', + 'contourWkt' + ], + ...modeRows(normalizedModes) + ]; + downloadCsv(`complex-plan-${runId}.csv`, rows); + showAlert(`Отчет по расчету #${runId} сформирован`, 'success'); + } catch (error) { + showAlert(error.message || 'Не удалось сформировать отчет'); + } finally { + reportButton.disabled = false; + } + } + + document.getElementById('complex-plan-refresh').addEventListener('click', () => loadRuns()); + document.getElementById('complex-plan-process-form').addEventListener('submit', submitProcessForm); + document.getElementById('complex-plan-report').addEventListener('click', downloadReport); + loadRuns(); +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/css/catalog.css b/services/pcp-ui-service/src/main/resources/static/css/catalog.css new file mode 100644 index 0000000..5d43624 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/catalog.css @@ -0,0 +1,145 @@ +.catalog-page { + min-height: calc(100vh - 6rem); +} + +.catalog-card { + border: 1px solid #dbe5ef; + border-radius: 1rem; + box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08); +} + +.catalog-card .card-header { + background: linear-gradient(135deg, #edf5ff, #f7fbff); + border-bottom: 1px solid #dbe5ef; + border-radius: 1rem 1rem 0 0; +} + +.catalog-list { + max-height: 68vh; + overflow: auto; +} + +.catalog-list-card { + display: flex; + flex-direction: column; +} + +.catalog-list-card .catalog-list-search { + flex: 0 0 auto; +} + +.catalog-list-card .catalog-list { + flex: 1 1 auto; + min-height: 0; +} + +.catalog-list-card .catalog-list-page-scroll { + max-height: none; + overflow: visible; +} + +.catalog-list .table tbody tr { + cursor: pointer; +} + +.catalog-list .table tbody tr.table-active { + --bs-table-bg: #e9f3ff; +} + +.catalog-section-title { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #52708f; +} + +.catalog-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.catalog-helper { + color: #5e7288; + font-size: 0.9rem; +} + +.catalog-checkbox-list { + max-height: 20rem; + overflow: auto; + border: 1px solid #dbe5ef; + border-radius: 0.75rem; + padding: 0.75rem; + background: #fbfdff; +} + +.catalog-checkbox-list .form-check { + padding: 0.35rem 0 0.35rem 1.8rem; +} + +.catalog-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.25rem 0.65rem; + background: #eef6ff; + color: #36516e; + font-size: 0.85rem; + margin: 0.15rem; +} + +.catalog-color-preview { + width: 100%; + height: 2.75rem; + border-radius: 0.75rem; + border: 1px solid #dbe5ef; + background: rgb(255, 0, 0); +} + +.catalog-empty { + padding: 1rem; + border: 1px dashed #bfd0e2; + border-radius: 0.75rem; + color: #5e7288; + background: #fbfdff; +} + +.catalog-slot-indicator { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + border: 1px solid #9caec2; + background: #e5ebf1; +} + +.catalog-slot-indicator-active { + border-color: #248a55; + background: #2fb66d; + box-shadow: 0 0 0 0.2rem rgba(47, 182, 109, 0.16); +} + +.catalog-slot-summary { + border-top: 1px solid #dbe5ef; + padding-top: 1rem; +} + +.catalog-slot-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.catalog-summary-label { + color: #5e7288; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; +} + +.catalog-summary-value { + color: #24364a; + font-size: 0.98rem; + font-weight: 700; +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/complex-plan.css b/services/pcp-ui-service/src/main/resources/static/css/complex-plan.css new file mode 100644 index 0000000..90ed781 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/complex-plan.css @@ -0,0 +1,127 @@ +.complex-plan-page { + min-height: calc(100vh - 6rem); +} + +.complex-plan-panel { + border: 1px solid #dbe5ef; + border-radius: 0.75rem; + background: #ffffff; + box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08); + overflow: hidden; +} + +.complex-plan-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + background: #f7fbff; + border-bottom: 1px solid #dbe5ef; +} + +.complex-plan-section-title { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #52708f; +} + +.complex-plan-helper { + color: #5e7288; + font-size: 0.9rem; +} + +.complex-plan-table-wrap { + max-height: calc(100vh - 24rem); + overflow: auto; +} + +.complex-plan-table-wrap th, +.complex-plan-table-wrap td { + white-space: nowrap; +} + +.complex-plan-run-row { + cursor: pointer; +} + +.complex-plan-run-row.active td { + background: #eef6ff; +} + +.complex-plan-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-top: 1px solid #dbe5ef; + background: #ffffff; +} + +.complex-plan-form { + padding: 1rem 1.25rem 1.25rem; +} + +.complex-plan-delete-btn { + padding: 0.15rem 0.45rem; +} + +.complex-plan-detail { + padding: 1.25rem; + min-height: 24rem; +} + +.complex-plan-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.complex-plan-field { + border-bottom: 1px solid #eef4fb; + padding-bottom: 0.75rem; +} + +.complex-plan-field-label { + color: #6b7d90; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 0.2rem; +} + +.complex-plan-field-value { + font-weight: 600; + word-break: break-word; +} + +.complex-plan-json { + background: #f7fbff; + border: 1px solid #dbe5ef; + border-radius: 0.5rem; + padding: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + max-height: 18rem; + overflow: auto; +} + +.complex-plan-added-surveys { + border: 1px solid #dbe5ef; + border-radius: 0.5rem; + overflow: hidden; +} + +.complex-plan-added-surveys th, +.complex-plan-added-surveys td { + padding: 0.5rem 0.75rem; +} + +@media (max-width: 768px) { + .complex-plan-detail-grid { + grid-template-columns: 1fr; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/current-plans.css b/services/pcp-ui-service/src/main/resources/static/css/current-plans.css new file mode 100644 index 0000000..06f27b5 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/current-plans.css @@ -0,0 +1,256 @@ +.current-plans-page { + height: calc(100vh - 4.25rem); + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + padding-bottom: 0 !important; +} + +.current-plans-page > .d-flex:first-child { + flex: 0 0 auto; + margin-bottom: 0.75rem !important; +} + +#current-plans-alert { + flex: 0 0 auto; +} + +.current-plans-layout { + flex: 1 1 auto; + min-height: 0; + height: auto; + overflow: hidden; +} + +.current-plans-layout > [class*="col-"] { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.current-plans-layout > [class*="col-"] > .catalog-card, +.current-plans-layout > [class*="col-"] > .current-plans-main-column { + width: 100%; +} + +.current-plans-main-column { + min-height: 0; + height: 100%; + width: 100%; +} + +.current-plans-sidebar, +.current-plans-missions-card, +.current-plans-modes-card { + overflow: hidden; +} + +.current-plans-sidebar { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.current-plans-sidebar > .card-header, +.current-plans-sidebar > .card-body:not(.catalog-list) { + flex: 0 0 auto; +} + +.current-plans-sidebar .catalog-list { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow-y: auto; + overflow-x: hidden; +} + +.current-plans-missions-card { + display: flex; + flex: 0 0 auto; + flex-direction: column; + max-height: none; + min-height: 0; +} + +.current-plans-missions-card .card-body { + min-height: 0; + overflow: hidden; +} + +.current-plans-missions-card .current-plans-table-wrap { + max-height: 15rem; + overflow-x: auto; + overflow-y: hidden; +} + +.current-plans-modes-card { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + +.current-plans-modes-card .card-header { + flex: 0 0 auto; +} + +.current-plans-modes-card .card-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.current-plans-modes-card .current-plans-table-wrap { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow: auto; +} + +.current-plans-modes-card .catalog-empty { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + min-height: 12rem; +} + +.current-plans-satellites-table tbody tr, +#current-plans-missions-body tr { + cursor: pointer; +} + +.current-plans-selected-row > td { + background: rgba(13, 110, 253, 0.12) !important; +} + +.current-plans-table-wrap { + overflow: auto; +} + +.current-plans-pagination { + white-space: nowrap; +} + +.current-plans-id { + font-family: var(--bs-font-monospace); + font-size: 0.82rem; +} + +.current-plans-main-text { + font-weight: 600; +} + +.current-plans-sub-text { + color: #6c757d; + font-size: 0.82rem; +} + +.current-plans-badge { + display: inline-flex; + align-items: center; + max-width: 100%; + 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); +} + +.current-plans-mode-params { + min-width: 11rem; +} + +#current-plans-modes-body tr.current-plans-mode-row-drop > td { + background: rgba(25, 135, 84, 0.14) !important; +} + +#current-plans-modes-body tr.current-plans-mode-row-slots > td { + background: rgba(255, 193, 7, 0.18) !important; +} + +#current-plans-modes-body tr.current-plans-mode-row-drop:hover > td { + background: rgba(25, 135, 84, 0.22) !important; +} + +#current-plans-modes-body tr.current-plans-mode-row-slots:hover > td { + background: rgba(255, 193, 7, 0.28) !important; +} + +.current-plans-badge-drop { + border-color: rgba(25, 135, 84, 0.45); + color: #0f5132; + background: rgba(25, 135, 84, 0.14); +} + +.current-plans-badge-slots { + border-color: rgba(255, 193, 7, 0.55); + color: #664d03; + background: rgba(255, 193, 7, 0.22); +} + +@media (max-width: 1199.98px) { + .current-plans-page { + height: auto; + min-height: 0; + overflow: visible; + padding-bottom: 1rem !important; + } + + .current-plans-layout { + height: auto; + min-height: 0; + overflow: visible; + } + + .current-plans-layout > [class*="col-"] { + display: block; + min-height: auto; + height: auto; + } + + .current-plans-main-column, + .current-plans-sidebar, + .current-plans-missions-card, + .current-plans-modes-card, + .current-plans-missions-card .card-body, + .current-plans-modes-card .card-body { + height: auto; + min-height: auto; + } + + .current-plans-missions-card { + max-height: none; + } + + .current-plans-sidebar .catalog-list, + .current-plans-table-wrap, + .current-plans-missions-card .current-plans-table-wrap, + .current-plans-modes-card .current-plans-table-wrap { + max-height: none; + overflow: visible; + } +} + +.current-plans-actions-col { + min-width: 13rem; +} + +.current-plans-actions-cell { + min-width: 13rem; + white-space: nowrap; +} + +.current-plans-action-btn + .current-plans-action-btn { + margin-left: 0.35rem; +} + +#current-plans-create-modal .form-label { + font-weight: 600; +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/dynamic-plan.css b/services/pcp-ui-service/src/main/resources/static/css/dynamic-plan.css new file mode 100644 index 0000000..e1475da --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/dynamic-plan.css @@ -0,0 +1,172 @@ +.dynamic-plan-page { + min-height: calc(100vh - 6rem); +} + +.dynamic-plan-panel { + border: 1px solid #dbe5ef; + border-radius: 0.5rem; + background: #ffffff; + box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08); + overflow: hidden; +} + +.dynamic-plan-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + background: #f7fbff; + border-bottom: 1px solid #dbe5ef; +} + +.dynamic-plan-section-title { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #52708f; +} + +.dynamic-plan-helper { + color: #5e7288; + font-size: 0.9rem; +} + +.dynamic-plan-form, +.dynamic-plan-result { + padding: 1rem 1.25rem 1.25rem; +} + +.dynamic-plan-runs { + padding: 0.75rem; +} + +.dynamic-plan-mode-group { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.dynamic-plan-mode-group .btn { + width: 100%; +} + +.dynamic-plan-revolution-group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.dynamic-plan-revolution-group .btn { + width: 100%; +} + +.dynamic-plan-run-item { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + border: 1px solid #e5edf6; + border-radius: 0.5rem; + background: #ffffff; + padding: 0.75rem; + text-align: left; +} + +.dynamic-plan-run-item + .dynamic-plan-run-item { + margin-top: 0.5rem; +} + +.dynamic-plan-run-main { + min-width: 0; +} + +.dynamic-plan-run-title { + font-weight: 700; + color: #2c4057; + word-break: break-word; +} + +.dynamic-plan-run-details { + margin-top: 0.25rem; + color: #63788f; + font-size: 0.82rem; +} + +.dynamic-plan-run-status { + white-space: nowrap; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + color: #52708f; +} + +.dynamic-plan-result-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.dynamic-plan-map-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + border: 1px solid #dbe5ef; + border-radius: 0.5rem; + background: #f7fbff; + padding: 0.75rem; +} + +.dynamic-plan-map-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + +.dynamic-plan-field { + border-bottom: 1px solid #eef4fb; + padding-bottom: 0.75rem; +} + +.dynamic-plan-field-label { + color: #6b7d90; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 0.2rem; +} + +.dynamic-plan-field-value { + font-weight: 600; + word-break: break-word; +} + +.dynamic-plan-json { + background: #f7fbff; + border: 1px solid #dbe5ef; + border-radius: 0.5rem; + padding: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + max-height: 18rem; + overflow: auto; +} + +@media (max-width: 768px) { + .dynamic-plan-result-grid { + grid-template-columns: 1fr; + } + + .dynamic-plan-map-controls { + align-items: stretch; + flex-direction: column; + } + + .dynamic-plan-map-actions { + justify-content: flex-start; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/group-state.css b/services/pcp-ui-service/src/main/resources/static/css/group-state.css new file mode 100644 index 0000000..1024b3a --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/group-state.css @@ -0,0 +1,134 @@ +.group-state-sidebar .catalog-list { + max-height: 76vh; +} + +.group-state-interval-cell { + min-width: 16rem; + font-size: 0.9rem; + color: #42576d; +} + +.group-state-time-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 14rem; + gap: 1rem; +} + +.group-state-table-wrap { + overflow: auto; +} + +.group-state-matrix-table { + min-width: 48rem; +} + +.group-state-matrix-table thead th { + min-width: 12rem; + background: #f6faff; + vertical-align: bottom; +} + +.group-state-matrix-table tbody th { + min-width: 12rem; + background: #f9fbfe; +} + +.group-state-matrix-cell { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.9rem; + color: #344b63; +} + +.group-state-matrix-diagonal { + text-align: center; + color: #7c8ea1; + font-weight: 600; +} + +.group-state-chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.group-state-chart-card { + border: 1px solid #dbe5ef; + border-radius: 1rem; + padding: 1rem; + background: linear-gradient(180deg, #fbfdff, #f4f9ff); +} + +.group-state-chart-title { + font-size: 0.95rem; + font-weight: 700; + color: #3e5c7a; + margin-bottom: 0.75rem; +} + +.group-state-chart-svg { + width: 100%; + height: auto; + display: block; +} + +.group-state-chart-ring { + fill: none; + stroke: #c8d7e8; + stroke-width: 2; +} + +.group-state-chart-inner { + fill: #ffffff; + stroke: #e6eef7; + stroke-width: 1.5; +} + +.group-state-chart-center { + font-size: 0.9rem; + fill: #53708d; + font-weight: 700; +} + +.group-state-chart-center-value { + font-size: 1.5rem; + fill: #1f3650; + font-weight: 700; +} + +.group-state-chart-legend { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.group-state-legend-item { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.9rem; + color: #3d536b; +} + +.group-state-legend-color { + width: 0.9rem; + height: 0.9rem; + border-radius: 999px; + flex: 0 0 auto; +} + +@media (max-width: 991.98px) { + .group-state-time-grid { + grid-template-columns: 1fr; + } + + .group-state-interval-cell { + min-width: 0; + } + + .group-state-matrix-table { + min-width: 36rem; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/map.css b/services/pcp-ui-service/src/main/resources/static/css/map.css new file mode 100644 index 0000000..1663969 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/map.css @@ -0,0 +1,732 @@ + + .main-container { + display: flex; + flex-direction: row; + width: 100%; + height: 100%; + } + + .sidebar { + width: 700px; + min-width: 700px; + max-width: 700px; + height: 100%; + overflow-y: auto; + background-color: #f8f9fa; + border-right: 1px solid #dee2e6; + display: flex; + flex-direction: column; + } + + .sidebar-content { + padding: 1rem; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + .sidebar-header { + padding-bottom: 1rem; + border-bottom: 1px solid #dee2e6; + margin-bottom: 1rem; + } + + .sidebar-controls { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #dee2e6; + } + + .cesium-container { + flex: 1; + height: 100%; + position: relative; + min-width: 0; + } + + #cesiumContainer { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + + /* Стили для тепловой карты */ + .heatmap-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 1rem; + } + + .heatmap-toggle .btn { + flex: 1; + } + + .switch { + position: relative; + display: inline-block; + width: 60px; + height: 34px; + margin-left: 10px; + } + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; + } + + .slider:before { + position: absolute; + content: ""; + height: 26px; + width: 26px; + left: 4px; + bottom: 4px; + background-color: white; + transition: .4s; + } + + input:checked + .slider { + background-color: #2196F3; + } + + input:checked + .slider:before { + transform: translateX(26px); + } + + .slider.round { + border-radius: 34px; + } + + .slider.round:before { + border-radius: 50%; + } + + /* Стили таблицы */ + .table-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + } + + .table-responsive { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border: 1px solid #dee2e6; + border-radius: 4px; + background-color: white; + overflow: hidden; + /* Прокрутка теперь здесь */ + overflow-y: auto; + } + + .table { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin-bottom: 0; + width: 100%; + } + + .table thead { + flex: 0 0 auto; + display: table; + width: 100%; + table-layout: fixed; + position: sticky; + top: 0; + z-index: 2; + background-color: #f8f9fa; + } + + .table tbody { + flex: 1 1 auto; + display: block; + /* Убираем overflow-y: auto отсюда */ + } + + .table tbody tr { + display: table; + width: 100%; + table-layout: fixed; + cursor: pointer; + transition: background-color 0.2s; + } + + .table tbody tr:hover { + background-color: #f8f9fa; + } + + .table th, .table td { + padding: 0.5rem 0.75rem; + vertical-align: middle; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .table thead th { + font-weight: 600; + background-color: #f8f9fa; + border-bottom: 2px solid #dee2e6; + /* Убираем position: sticky отсюда */ + } + + /* Фиксированные ширины столбцов */ + #table-stations th:nth-child(1), + #table-stations td:nth-child(1) { + width: 40px; + min-width: 40px; + max-width: 40px; + text-align: center; + } + + #table-stations th:nth-child(2), + #table-stations td:nth-child(2) { + width: 60%; + min-width: 60%; + max-width: 60%; + } + + #table-stations th:nth-child(3), + #table-stations td:nth-child(3) { + width: 40%; + min-width: 40%; + max-width: 40%; + } + + + + + /* Фиксированные ширины столбцов */ + #table-request th:nth-child(1), + #table-request td:nth-child(1) { + width: 40px; + min-width: 40px; + max-width: 40px; + text-align: center; + } + + #table-request th:nth-child(2), + #table-request td:nth-child(2) { + width: 45%; + min-width: 45%; + max-width: 45%; + } + + #table-request th:nth-child(3), + #table-request td:nth-child(3) { + width: 10%; + min-width: 10%; + max-width: 10%; + } + + + #table-request th:nth-child(4), + #table-request td:nth-child(4) { + width: 25%; + min-width: 25%; + max-width: 25%; + } + + #table-request th:nth-child(5), + #table-request td:nth-child(5) { + width: 10%; + min-width: 10%; + max-width: 10%; + } + #table-request th:nth-child(6), + #table-request td:nth-child(6) { + width: 10%; + min-width: 10%; + max-width: 10%; + } + #table-request th:nth-child(7), + #table-request td:nth-child(7) { + width: 10%; + min-width: 10%; + max-width: 10%; + } + + + + #slotsTable th:nth-child(1), + #slotsTable td:nth-child(1) { + width: 14%; + min-width: 14%; + max-width: 14%; + } + + #slotsTable th:nth-child(2), + #slotsTable td:nth-child(2) { + width: 14%; + min-width:14%; + max-width: 14%; + } + + #slotsTable th:nth-child(3), + #slotsTable td:nth-child(3) { + width: 28%; + min-width: 28%; + max-width: 28%; + } + #slotsTable th:nth-child(4), + #slotsTable td:nth-child(4) { + width: 14%; + min-width: 14%; + max-width: 14%; + } + + #slotsTable th:nth-child(5), + #slotsTable td:nth-child(5) { + width: 14%; + min-width: 14%; + max-width: 14%; + } + #slotsTable th:nth-child(6), + #slotsTable td:nth-child(6) { + width: 8%; + min-width: 8%; + max-width: 8%; + } + #slotsTable th:nth-child(7), + #slotsTable td:nth-child(7) { + width: 8%; + min-width: 8%; + max-width: 8%; + } + + + + #table-satellites th:nth-child(1), + #table-satellites td:nth-child(1) { + width: 20%; + min-width: 20%; + max-width: 20%; + } + + #table-satellites th:nth-child(2), + #table-satellites td:nth-child(2) { + width: 60%; + min-width: 60%; + max-width: 60%; + } + + #table-satellites th:nth-child(3), + #table-satellites td:nth-child(3) { + width: 10%; + min-width: 10%; + max-width: 20%; + } + + #table-satellites th:nth-child(4), + #table-satellites td:nth-child(4) { + width: 10%; + min-width: 10%; + max-width: 20%; + } + + + /* Стили для сортировки */ + .table th.sortable { + cursor: pointer; + position: relative; + user-select: none; + transition: background-color 0.2s; + padding-right: 25px; + } + + .table th.sortable:hover { + background-color: #e9ecef; + } + + .sort-icon { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 0.8em; + opacity: 0.5; + transition: opacity 0.2s, color 0.2s; + } + + .table th.sortable:hover .sort-icon { + opacity: 0.8; + } + + .table th.sortable.asc .sort-icon, + .table th.sortable.desc .sort-icon { + opacity: 1; + color: #0d6efd; + } + + .table th.sortable.desc .sort-icon i { + transform: rotate(180deg); + } + + /* Чекбоксы */ + .request-checkbox { + cursor: pointer; + margin: 0; + width: 16px; + height: 16px; + } + + #selectAllCheckbox { + cursor: pointer; + width: 16px; + height: 16px; + margin: 0; + } + + /* Бейджи */ + .badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + min-width: 40px; + text-align: center; + display: inline-block; + } + + /* Счетчик */ + .selected-counter { + font-size: 0.8rem; + color: #6c757d; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #dee2e6; + } + + /* Сообщение "Нет заявок" */ + .no-requests { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #6c757d; + font-style: italic; + } + + /* Полоса прокрутки теперь у .table-responsive */ + .table-responsive::-webkit-scrollbar { + width: 8px; + } + + .table-responsive::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + .table-responsive::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + } + + .table-responsive::-webkit-scrollbar-thumb:hover { + background: #555; + } + + + +/* Стили для таблицы слотов */ +.slots-container { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.slots-header { + padding: 10px 0; + border-bottom: 2px solid #0d6efd; +} + +.slots-actions .btn { + padding: 5px 10px; + font-size: 0.85rem; +} + +.slots-actions .btn i { + font-size: 0.9rem; +} + +#slotsTable { + font-size: 0.85rem; +} + +#slotsTable th { + padding: 8px 10px; + font-weight: 600; + position: sticky; + top: 0; + background-color: #f8f9fa; + z-index: 10; +} + +#slotsTable td { + padding: 6px 10px; + vertical-align: middle; +} + +#slotsTable .sortable-slot { + cursor: pointer; + user-select: none; +} + +#slotsTable .sortable-slot:hover { + background-color: #e9ecef; +} + +#slotsTable .sortable-slot .sort-icon { + margin-left: 5px; + font-size: 0.8rem; + color: #6c757d; +} + +#slotsTable .sortable-slot:hover .sort-icon { + color: #0d6efd; +} + +#slotsTable .badge { + font-size: 0.75rem; + padding: 4px 8px; +} + +#slotsTable .text-nowrap { + white-space: nowrap; +} + +.coverage-satellites-list { + max-height: 150px; + overflow-y: auto; + background-color: #fff; +} + +.slots-summary { + font-size: 0.85rem; +} + +.slots-summary .row { + row-gap: 5px; +} + +.slots-summary small { + display: block; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .slots-summary .row > div { + flex: 0 0 50%; + max-width: 50%; + } +} + +@media (max-width: 576px) { + .slots-summary .row > div { + flex: 0 0 100%; + max-width: 100%; + } + + .slots-actions .btn { + margin-bottom: 5px; + } + + .slots-actions .btn:not(:last-child) { + margin-right: 0; + } +} + + + + + +/* Панель инструментов карты */ +.cesium-toolbar { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toolbar-group { + background: rgba(255, 255, 255, 0.9); + border-radius: 6px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + padding: 5px; + backdrop-filter: blur(10px); + border: 1px solid rgba(0, 0, 0, 0.1); +} + +/* Кнопки панели инструментов */ +.cesium-toolbar-btn { + background: white; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + color: #495057; + transition: all 0.2s ease; + min-width: 120px; + text-align: left; +} + +.cesium-toolbar-btn:hover { + background: #e9ecef; + border-color: #adb5bd; + color: #0d6efd; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.cesium-toolbar-btn:active { + transform: translateY(0); +} + +.cesium-toolbar-btn.active { + background: #0d6efd; + border-color: #0d6efd; + color: white; + box-shadow: 0 2px 8px rgba(13, 110, 253, 0.4); +} + +.cesium-toolbar-btn.active:hover { + background: #0b5ed7; + border-color: #0b5ed7; +} + +.cesium-toolbar-btn i { + font-size: 1.1rem; +} + +.btn-label { + font-weight: 500; +} + +/* Информация о линейке */ +.ruler-info { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 6px; + padding: 10px 15px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.ruler-info-content { + display: flex; + align-items: center; + gap: 8px; +} + +.ruler-info i { + font-size: 1.2rem; +} + +.ruler-info .btn-close { + opacity: 0.7; + transition: opacity 0.2s; +} + +.ruler-info .btn-close:hover { + opacity: 1; +} + +/* Стили для линий линейки */ +.ruler-line { + position: absolute; + pointer-events: none; + z-index: 1000; + background: linear-gradient(90deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.8) 50%, rgba(255,255,255,0.2) 100%); + height: 2px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} + +/* Стили для маркеров точек */ +.ruler-marker { + position: absolute; + width: 12px; + height: 12px; + background: #ff4444; + border: 2px solid white; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 10px rgba(255, 68, 68, 0.8); + pointer-events: none; + z-index: 1001; +} + +/* Стили для меток расстояния */ +.ruler-label { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 1002; + white-space: nowrap; + transform: translate(-50%, -100%); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +/* Стили для временной линии при наведении */ +.ruler-temp-line { + position: absolute; + pointer-events: none; + z-index: 999; + height: 2px; + background: rgba(255, 255, 255, 0.5); + width: 0; +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/rva.css b/services/pcp-ui-service/src/main/resources/static/css/rva.css new file mode 100644 index 0000000..b193645 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/rva.css @@ -0,0 +1,74 @@ +.rva-page { + min-height: calc(100vh - 6rem); +} + +.rva-card { + border: 1px solid #dbe5ef; + border-radius: 1rem; + box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08); +} + +.rva-card .card-header { + background: linear-gradient(135deg, #edf5ff, #f7fbff); + border-bottom: 1px solid #dbe5ef; + border-radius: 1rem 1rem 0 0; +} + +.rva-section-title { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #52708f; +} + +.rva-helper { + color: #5e7288; + font-size: 0.9rem; +} + +.rva-stations { + max-height: 26rem; + overflow: auto; + border: 1px solid #dbe5ef; + border-radius: 0.75rem; + padding: 0.75rem; + background: #fbfdff; +} + +.rva-station-item { + display: flex; + align-items: flex-start; + width: 100%; + gap: 0.65rem; + padding: 0.55rem 0.25rem; + border-bottom: 1px solid #eef4fb; +} + +.rva-station-item.form-check { + margin-bottom: 0; + padding-left: 0; +} + +.rva-station-item .form-check-input { + margin-left: 0; + margin-top: 0.2rem; + flex-shrink: 0; +} + +.rva-station-label { + display: block; + min-width: 0; +} + +.rva-station-item:last-child { + border-bottom: 0; +} + +#rva-results-head th { + white-space: nowrap; +} + +#rva-results-body td { + white-space: nowrap; +} 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 new file mode 100644 index 0000000..077b9cf --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css @@ -0,0 +1,107 @@ +.satellite-pdcm-sidebar .catalog-list { + max-height: 76vh; +} + +.satellite-pdcm-sidebar th:first-child, +.satellite-pdcm-sidebar td:first-child { + width: 45%; +} + +.satellite-pdcm-summary-root { + min-height: 1.5rem; +} + +.satellite-pdcm-status { + display: inline-flex; + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + margin-right: 0.5rem; + vertical-align: middle; +} + +.satellite-pdcm-status-active { + background: #2f9e44; + box-shadow: 0 0 0 0.2rem rgba(47, 158, 68, 0.16); +} + +.satellite-pdcm-status-inactive { + background: #9aa9b8; + box-shadow: 0 0 0 0.2rem rgba(154, 169, 184, 0.16); +} + +.satellite-pdcm-interval-cell { + min-width: 10.5rem; + font-size: 0.85rem; + color: #42576d; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.3; +} + +.satellite-pdcm-satellite-name { + color: #1f3650; + font-size: 0.95rem; +} + +.satellite-pdcm-interval-line { + display: block; +} + +.satellite-pdcm-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.satellite-pdcm-summary-card { + border: 1px solid #dbe5ef; + border-radius: 0.9rem; + padding: 0.9rem 1rem; + background: linear-gradient(180deg, #fbfdff, #f5f9ff); +} + +.satellite-pdcm-summary-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6d8398; + margin-bottom: 0.25rem; +} + +.satellite-pdcm-summary-value { + color: #1f3650; + font-weight: 600; +} + +.satellite-pdcm-table-wrap { + overflow: auto; +} + +.satellite-pdcm-table { + min-width: 92rem; +} + +.satellite-pdcm-table thead th { + background: #f6faff; + white-space: nowrap; +} + +.satellite-pdcm-table td { + white-space: nowrap; +} + +@media (max-width: 991.98px) { + .satellite-pdcm-sidebar th:first-child, + .satellite-pdcm-sidebar td:first-child { + width: auto; + } + + .satellite-pdcm-interval-cell { + min-width: 0; + } + + .satellite-pdcm-table { + min-width: 72rem; + } +} 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 new file mode 100644 index 0000000..29138a6 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js @@ -0,0 +1,655 @@ +(() => { + const state = { + satellites: [], + filteredSatellites: [], + missions: [], + currentModes: [], + selectedSatelliteId: null, + selectedMissionId: null, + selectedMission: null, + missionsPage: 0, + missionsSize: 4, + missionsTotalPages: 0, + missionsTotalElements: 0 + }; + + const el = (id) => document.getElementById(id); + + document.addEventListener('DOMContentLoaded', () => { + bindEvents(); + loadSatellites(); + }); + + function bindEvents() { + el('current-plans-refresh')?.addEventListener('click', () => reloadAll()); + el('current-plans-satellite-filter')?.addEventListener('input', () => applySatelliteFilter()); + el('current-plans-missions-prev')?.addEventListener('click', () => changeMissionPage(-1)); + el('current-plans-missions-next')?.addEventListener('click', () => changeMissionPage(1)); + el('current-plans-create-mission')?.addEventListener('click', () => openCreateMissionDialog()); + el('current-plans-create-form')?.addEventListener('submit', (event) => createMission(event)); + el('current-plans-export-csv')?.addEventListener('click', () => exportSelectedMissionCsv()); + } + + function reloadAll() { + if (state.selectedSatelliteId) { + loadMissions(state.selectedSatelliteId, state.missionsPage, state.selectedMissionId); + } else { + loadSatellites(); + } + } + + async function loadSatellites() { + showAlert(''); + setCreateMissionEnabled(false); + clearMissions('Выберите спутник для загрузки миссий.'); + clearModes('Режимы появятся после выбора миссии.'); + setTableLoading('current-plans-satellites-body', 3, 'Загрузка спутников...'); + + try { + const satellites = await fetchJson('/api/current-plans/satellites'); + state.satellites = Array.isArray(satellites) ? satellites : []; + applySatelliteFilter(); + } catch (error) { + state.satellites = []; + state.filteredSatellites = []; + renderSatellites(); + showAlert(error.message || 'Не удалось загрузить список спутников.', 'danger'); + } + } + + function applySatelliteFilter() { + const query = (el('current-plans-satellite-filter')?.value || '').trim().toLowerCase(); + state.filteredSatellites = state.satellites.filter((satellite) => { + const text = [ + satellite.id, + satellite.noradId, + satellite.code, + satellite.name, + satellite.typeCode + ].filter((value) => value !== null && value !== undefined) + .join(' ') + .toLowerCase(); + return !query || text.includes(query); + }); + renderSatellites(); + } + + function renderSatellites() { + const tbody = el('current-plans-satellites-body'); + if (!tbody) return; + + if (!state.filteredSatellites.length) { + tbody.innerHTML = emptyRow(3, 'Спутники не найдены.'); + return; + } + + tbody.innerHTML = state.filteredSatellites.map((satellite) => { + const selected = Number(satellite.id) === Number(state.selectedSatelliteId) ? ' current-plans-selected-row' : ''; + const title = escapeHtml(satellite.name || satellite.code || `КА ${satellite.id}`); + const code = escapeHtml(satellite.code || '—'); + const norad = satellite.noradId ? `NORAD ${escapeHtml(satellite.noradId)}` : 'NORAD —'; + return ` + + ${escapeHtml(satellite.id)} + +
${title}
+
${code} · ${norad}
+ + ${escapeHtml(satellite.typeCode || '—')} + `; + }).join(''); + + tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => { + row.addEventListener('click', () => selectSatellite(Number(row.dataset.satelliteId))); + }); + } + + function selectSatellite(satelliteId) { + state.selectedSatelliteId = satelliteId; + state.selectedMissionId = null; + state.selectedMission = null; + state.missionsPage = 0; + renderSatellites(); + const satellite = selectedSatellite(); + el('current-plans-selected-satellite').textContent = satelliteLabel(satellite); + setCreateMissionEnabled(true); + clearModes('Выберите миссию в верхней таблице.'); + loadMissions(satelliteId, 0); + } + + async function loadMissions(satelliteId, page, missionIdToSelect = null) { + setTableLoading('current-plans-missions-body', 6, 'Загрузка миссий...'); + el('current-plans-missions-empty')?.classList.add('d-none'); + el('current-plans-missions-wrap')?.classList.remove('d-none'); + setMissionPaging(false); + + try { + const data = await fetchJson(`/api/current-plans/satellites/${encodeURIComponent(satelliteId)}/missions?page=${page}&size=${state.missionsSize}`); + state.missionsPage = data.page || 0; + state.missionsTotalPages = data.totalPages || 0; + state.missionsTotalElements = data.totalElements || 0; + state.missions = data.items || []; + renderMissions(state.missions); + updateMissionPaging(); + if (missionIdToSelect && state.missions.some((mission) => mission.missionId === missionIdToSelect)) { + selectMission(missionIdToSelect); + } + } catch (error) { + clearMissions('Не удалось загрузить миссии выбранного спутника.'); + showAlert(error.message || 'Не удалось загрузить миссии выбранного спутника.', 'danger'); + } + } + + function renderMissions(missions) { + const tbody = el('current-plans-missions-body'); + if (!tbody) return; + + if (!missions.length) { + tbody.innerHTML = emptyRow(6, 'Для выбранного спутника миссии не найдены.'); + clearModes('Выберите миссию в верхней таблице.'); + return; + } + + tbody.innerHTML = missions.map((mission) => { + const selected = mission.missionId === state.selectedMissionId ? ' current-plans-selected-row' : ''; + return ` + + ${shortUuid(mission.missionId)} + ${formatDateTime(mission.missionStart)} + ${formatDateTime(mission.missionStop)} + ${escapeHtml(mission.station || '—')} + ${escapeHtml(mission.status || '—')} + + + + + `; + }).join(''); + + tbody.querySelectorAll('tr[data-mission-id]').forEach((row) => { + row.addEventListener('click', () => selectMission(row.dataset.missionId)); + }); + tbody.querySelectorAll('button[data-action]').forEach((button) => { + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const missionId = button.dataset.missionId; + if (button.dataset.action === 'calculate-surveys') { + calculateSurveys(missionId, button); + } else if (button.dataset.action === 'calculate-drops') { + calculateDrops(missionId, button); + } + }); + }); + } + + function selectMission(missionId) { + state.selectedMissionId = missionId; + state.selectedMission = state.missions.find((mission) => mission.missionId === missionId) || null; + el('current-plans-selected-mission').textContent = missionCaption(state.selectedMission, missionId); + el('current-plans-missions-body')?.querySelectorAll('tr').forEach((row) => { + row.classList.toggle('current-plans-selected-row', row.dataset.missionId === missionId); + }); + loadModes(missionId); + } + + async function loadModes(missionId) { + setTableLoading('current-plans-modes-body', 6, 'Загрузка режимов...'); + el('current-plans-modes-empty')?.classList.add('d-none'); + el('current-plans-modes-wrap')?.classList.remove('d-none'); + setExportEnabled(false); + + try { + const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`); + state.currentModes = Array.isArray(modes) ? modes : []; + renderModes(state.currentModes); + setExportEnabled(true); + return state.currentModes; + } catch (error) { + clearModes('Не удалось загрузить режимы выбранной миссии.'); + showAlert(error.message || 'Не удалось загрузить режимы выбранной миссии.', 'danger'); + return []; + } + } + + function renderModes(modes) { + const tbody = el('current-plans-modes-body'); + if (!tbody) return; + + if (!modes.length) { + tbody.innerHTML = emptyRow(6, 'В выбранной миссии нет режимов работы.'); + return; + } + + tbody.innerHTML = modes.map((mode) => { + const type = mode.type || '—'; + const duration = formatNumber(mode.duration); + const rowClass = modeRowClass(mode); + const badgeClass = modeBadgeClass(mode); + return ` + + ${escapeHtml(type)} + ${formatDateTime(mode.timeStart)} + ${escapeHtml(mode.revolution ?? '—')} + ${duration} + ${modeParams(mode)} + ${modeStatus(mode)} + `; + }).join(''); + } + + function modeRowClass(mode) { + if (mode.type === 'DROP') return 'current-plans-mode-row-drop'; + if (mode.source === 'SLOTS') 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'; + return ''; + } + + function modeParams(mode) { + if (mode.type === 'DROP') { + const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0; + return ` +
Станция: ${escapeHtml(mode.station || '—')}
+
Съёмок: ${surveys}
`; + } + + const booked = Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length + ? ` · slots: ${mode.bookedSlotIds.length}` + : ''; + return ` +
Крен: ${formatNumber(mode.roll)}°
+
lat ${formatNumber(mode.lat)}, lon ${formatNumber(mode.longitude)}${booked}
`; + } + + function modeStatus(mode) { + if (mode.type === 'DROP') { + return 'DROP'; + } + const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : ''; + return ` +
${escapeHtml(mode.status || '—')}
+
${escapeHtml(mode.source || '—')}
`; + } + + function openCreateMissionDialog() { + if (!state.selectedSatelliteId) { + showAlert('Сначала выберите спутник.', 'warning'); + return; + } + const satellite = selectedSatellite(); + el('current-plans-create-satellite').value = satelliteLabel(satellite); + const startInput = el('current-plans-create-start'); + const stopInput = el('current-plans-create-stop'); + if (startInput && !startInput.value) startInput.value = toDateTimeLocalValue(new Date()); + if (stopInput && !stopInput.value) stopInput.value = toDateTimeLocalValue(new Date(Date.now() + 24 * 60 * 60 * 1000)); + const modalElement = el('current-plans-create-modal'); + if (window.bootstrap && modalElement) { + window.bootstrap.Modal.getOrCreateInstance(modalElement).show(); + } + } + + async function createMission(event) { + event.preventDefault(); + if (!state.selectedSatelliteId) { + showAlert('Сначала выберите спутник.', 'warning'); + return; + } + + const missionStart = el('current-plans-create-start')?.value; + const missionStop = el('current-plans-create-stop')?.value; + if (!missionStart || !missionStop) { + showAlert('Укажите начало и конец миссии.', 'warning'); + return; + } + if (new Date(missionStart).getTime() > new Date(missionStop).getTime()) { + showAlert('Время начала миссии должно быть меньше или равно времени окончания.', 'warning'); + return; + } + + const button = el('current-plans-create-submit'); + setButtonLoading(button, true, 'Создание...'); + const body = { + satelliteId: Number(state.selectedSatelliteId), + station: trimToNull(el('current-plans-create-station')?.value), + missionStart, + missionStop, + status: trimToNull(el('current-plans-create-status')?.value) || 'NEW' + }; + + try { + const mission = await fetchJson('/api/current-plans/missions', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + body: JSON.stringify(body) + }); + closeCreateMissionDialog(); + showAlert(`План создан: ${mission?.missionId || ''}`, 'success'); + await loadMissions(state.selectedSatelliteId, 0, mission?.missionId); + } catch (error) { + showAlert(error.message || 'Не удалось создать план.', 'danger'); + } finally { + setButtonLoading(button, false); + } + } + + async function calculateSurveys(missionId, button) { + setButtonLoading(button, true, 'Расчёт...'); + try { + const result = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/surveys/calculate`, { + method: 'POST', + headers: {'Accept': 'application/json'} + }); + showAlert(surveyCalculationMessage(result), 'success'); + if (state.selectedMissionId === missionId) { + await loadModes(missionId); + } + } catch (error) { + showAlert(error.message || 'Не удалось рассчитать съёмку.', 'danger'); + } finally { + setButtonLoading(button, false); + } + } + + async function calculateDrops(missionId, button) { + setButtonLoading(button, true, 'Расчёт...'); + try { + await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/drops/calculate`, { + method: 'POST', + headers: {'Accept': 'application/json'} + }); + const modes = state.selectedMissionId === missionId + ? await loadModes(missionId) + : await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`); + showAlert(`Расчёт сбросов выполнен. ${modeCountsMessage(Array.isArray(modes) ? modes : [])}`, 'success'); + } catch (error) { + showAlert(error.message || 'Не удалось рассчитать сбросы.', 'danger'); + } finally { + setButtonLoading(button, false); + } + } + + function exportSelectedMissionCsv() { + if (!state.selectedMissionId) { + showAlert('Выберите миссию для сохранения CSV.', 'warning'); + return; + } + const mission = state.selectedMission || state.missions.find((item) => item.missionId === state.selectedMissionId); + const satellite = selectedSatellite(); + const modes = state.currentModes || []; + const csv = buildMissionCsv(mission, satellite, modes); + const filename = `mission-${state.selectedMissionId}-modes.csv`; + downloadTextFile(filename, csv, 'text/csv;charset=utf-8;'); + } + + function buildMissionCsv(mission, satellite, modes) { + const lines = []; + lines.push(['Миссия', mission?.missionId || state.selectedMissionId].map(csvCell).join(';')); + lines.push(['Спутник ID', mission?.satelliteId || state.selectedSatelliteId].map(csvCell).join(';')); + lines.push(['Спутник', satelliteLabel(satellite)].map(csvCell).join(';')); + lines.push(['Начало миссии', mission?.missionStart || ''].map(csvCell).join(';')); + lines.push(['Конец миссии', mission?.missionStop || ''].map(csvCell).join(';')); + lines.push(['Станция', mission?.station || ''].map(csvCell).join(';')); + lines.push(['Статус', mission?.status || ''].map(csvCell).join(';')); + lines.push(['Количество режимов', modes.length].map(csvCell).join(';')); + lines.push(['Состав режимов', modeCountsPlain(modes)].map(csvCell).join(';')); + lines.push(''); + lines.push(['Тип', 'ID', 'Plan ID', 'Начало', 'Виток', 'Длительность, с', 'Статус', 'Источник', 'Крен', 'Широта', 'Долгота', 'Станция', 'Связанные съёмки', 'Booked slots'].map(csvCell).join(';')); + modes.forEach((mode) => { + lines.push([ + mode.type || '', + mode.id ?? '', + mode.planId ?? '', + mode.timeStart || '', + mode.revolution ?? '', + mode.duration ?? '', + mode.status || '', + mode.source || '', + mode.roll ?? '', + mode.lat ?? '', + mode.longitude ?? '', + mode.station || '', + Array.isArray(mode.surveys) ? mode.surveys.join('|') : '', + Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds.join('|') : '' + ].map(csvCell).join(';')); + }); + return '\ufeff' + lines.join('\r\n'); + } + + function changeMissionPage(delta) { + if (!state.selectedSatelliteId) return; + const next = state.missionsPage + delta; + if (next < 0 || (state.missionsTotalPages > 0 && next >= state.missionsTotalPages)) return; + state.selectedMissionId = null; + state.selectedMission = null; + clearModes('Выберите миссию в верхней таблице.'); + loadMissions(state.selectedSatelliteId, next); + } + + function updateMissionPaging() { + const pageText = state.missionsTotalPages > 0 + ? `${state.missionsPage + 1} / ${state.missionsTotalPages}` + : '0 / 0'; + el('current-plans-missions-page').textContent = `${pageText} · ${state.missionsTotalElements}`; + setMissionPaging(true); + } + + function setMissionPaging(enabled) { + const hasPrev = enabled && state.missionsPage > 0; + const hasNext = enabled && state.missionsTotalPages > 0 && state.missionsPage < state.missionsTotalPages - 1; + el('current-plans-missions-prev').disabled = !hasPrev; + el('current-plans-missions-next').disabled = !hasNext; + } + + function clearMissions(message) { + el('current-plans-missions-wrap')?.classList.add('d-none'); + el('current-plans-missions-empty')?.classList.remove('d-none'); + el('current-plans-missions-empty').textContent = message; + el('current-plans-missions-body').innerHTML = ''; + state.missions = []; + state.missionsTotalPages = 0; + state.missionsTotalElements = 0; + updateMissionPaging(); + } + + function clearModes(message) { + el('current-plans-modes-wrap')?.classList.add('d-none'); + el('current-plans-modes-empty')?.classList.remove('d-none'); + el('current-plans-modes-empty').textContent = message; + el('current-plans-modes-body').innerHTML = ''; + el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.'; + state.currentModes = []; + setExportEnabled(false); + } + + function setTableLoading(bodyId, colspan, message) { + const tbody = el(bodyId); + if (tbody) tbody.innerHTML = emptyRow(colspan, message); + } + + function selectedSatellite() { + return state.satellites.find((item) => Number(item.id) === Number(state.selectedSatelliteId)) || null; + } + + function satelliteLabel(satellite) { + if (!satellite) return `Спутник ${state.selectedSatelliteId}`; + const name = satellite.name || satellite.code || `КА ${satellite.id}`; + const type = satellite.typeCode ? `, тип ${satellite.typeCode}` : ''; + const norad = satellite.noradId ? `, NORAD ${satellite.noradId}` : ''; + return `${name} (ID ${satellite.id}${norad}${type})`; + } + + function missionCaption(mission, missionId) { + if (!mission) return `Миссия ${missionId}`; + return `Миссия ${mission.missionId}: ${formatDateTime(mission.missionStart)} — ${formatDateTime(mission.missionStop)}`; + } + + async function fetchJson(url, options = {}) { + const response = await fetch(url, options.headers ? options : {...options, headers: {'Accept': 'application/json'}}); + if (!response.ok) { + const text = await response.text(); + throw new Error(extractError(text) || `HTTP ${response.status}`); + } + if (response.status === 204) return null; + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + function extractError(text) { + if (!text) return ''; + try { + const json = JSON.parse(text); + return json.message || json.error || text; + } catch (_) { + return text; + } + } + + function showAlert(message, type = 'danger') { + const target = el('current-plans-alert'); + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + target.innerHTML = ``; + } + + function surveyCalculationMessage(result) { + if (!result) return 'Расчёт съёмки выполнен.'; + return [ + `Расчёт съёмки выполнен. Всего режимов съёмки: ${result.surveyModesCount ?? 0}`, + `COMPLAN: ${result.complanModesCount ?? 0}`, + `SLOTS: ${result.slotsModesCount ?? 0}`, + `MIXED: ${result.mixedModesCount ?? 0}`, + `MANUAL: ${result.manualModesCount ?? 0}`, + `booked slots: ${result.bookedSlotIdsCount ?? 0}` + ].join(' · '); + } + + function modeCountsMessage(modes) { + const counts = countModes(modes); + const parts = Object.keys(counts).sort().map((type) => `${type}: ${counts[type]}`); + return parts.length ? `Режимы: ${parts.join(' · ')}` : 'Режимы отсутствуют.'; + } + + function modeCountsPlain(modes) { + const counts = countModes(modes); + return Object.keys(counts).sort().map((type) => `${type}: ${counts[type]}`).join(', '); + } + + function countModes(modes) { + return (modes || []).reduce((acc, mode) => { + const type = mode.type || 'UNKNOWN'; + acc[type] = (acc[type] || 0) + 1; + return acc; + }, {}); + } + + function emptyRow(colspan, message) { + return `${escapeHtml(message)}`; + } + + function shortUuid(value) { + if (!value) return '—'; + return `${String(value).slice(0, 8)}…`; + } + + 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) { + const number = Number(value); + if (!Number.isFinite(number)) return '—'; + return number.toLocaleString('ru-RU', {maximumFractionDigits: 2}); + } + + 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())}`; + } + + function trimToNull(value) { + const trimmed = String(value || '').trim(); + return trimmed || null; + } + + function setCreateMissionEnabled(enabled) { + const button = el('current-plans-create-mission'); + if (button) button.disabled = !enabled; + } + + function setExportEnabled(enabled) { + const button = el('current-plans-export-csv'); + if (button) button.disabled = !enabled || !state.selectedMissionId; + } + + function setButtonLoading(button, loading, text = 'Загрузка...') { + if (!button) return; + if (loading) { + button.dataset.originalText = button.textContent; + button.textContent = text; + button.disabled = true; + } else { + button.textContent = button.dataset.originalText || button.textContent; + button.disabled = false; + } + } + + function closeCreateMissionDialog() { + const modalElement = el('current-plans-create-modal'); + if (window.bootstrap && modalElement) { + window.bootstrap.Modal.getOrCreateInstance(modalElement).hide(); + } + } + + function csvCell(value) { + const text = String(value ?? ''); + return `"${text.replaceAll('"', '""')}"`; + } + + function downloadTextFile(filename, content, type) { + const blob = new Blob([content], {type}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/dynamic_plan_map_layers.js b/services/pcp-ui-service/src/main/resources/static/dynamic_plan_map_layers.js new file mode 100644 index 0000000..43456cf --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/dynamic_plan_map_layers.js @@ -0,0 +1,326 @@ +(function () { + const storageKey = 'pcp.dynamicPlan.visibleRuns'; + const routePageLimit = 1000; + const intervalPageLimit = 1000; + const maxRoutesToDraw = 10000; + const maxIntervalsToDraw = 10000; + const dataSourcePrefix = 'dynamic_plan_run_'; + let mapViewer = null; + + function readRuns() { + try { + const value = window.localStorage.getItem(storageKey); + const parsed = value ? JSON.parse(value) : []; + return Array.isArray(parsed) ? parsed.slice(0, 1) : []; + } catch (error) { + console.warn('Failed to read dynamic plan map runs', error); + return []; + } + } + + function writeRuns(runs) { + window.localStorage.setItem(storageKey, JSON.stringify(runs)); + window.dispatchEvent(new CustomEvent('dynamic-plan-map-runs-changed', { detail: runs })); + } + + function normalizeRun(run) { + return { + runId: String(run.runId), + requestId: run.requestId ? String(run.requestId) : null, + routesCount: Number.isFinite(Number(run.routesCount)) ? Number(run.routesCount) : null, + addedAt: new Date().toISOString() + }; + } + + function showRun(run) { + if (!run || !run.runId) { + return; + } + const normalizedRun = normalizeRun(run); + writeRuns([normalizedRun]); + } + + function hideRun(runId) { + const id = String(runId); + writeRuns(readRuns().filter(item => String(item.runId) !== id)); + removeRunDataSource(id); + } + + function isRunVisible(runId) { + const id = String(runId); + return readRuns().some(item => String(item.runId) === id); + } + + function dataSourceName(runId) { + return `${dataSourcePrefix}${runId}`; + } + + function removeRunDataSource(runId) { + if (!mapViewer) { + return; + } + let dataSource = mapViewer.dataSources.getByName(dataSourceName(runId))[0]; + while (dataSource && mapViewer.dataSources.remove(dataSource)) { + dataSource = mapViewer.dataSources.getByName(dataSourceName(runId))[0]; + } + } + + function closeDegreesArrayHeights(positions) { + if (!Array.isArray(positions) || positions.length < 9) { + return positions; + } + + const first = positions.slice(0, 3); + const last = positions.slice(-3); + const isClosed = first.every((value, index) => value === last[index]); + return isClosed ? positions : positions.concat(first); + } + + function parsePolygonWktToDegreesArray(contourWkt) { + if (typeof contourWkt !== 'string') { + return null; + } + + const trimmed = contourWkt.trim(); + if (!trimmed.toUpperCase().startsWith('POLYGON')) { + return null; + } + + const body = trimmed + .replace(/^POLYGON\s*\(\s*\(/i, '') + .replace(/\)\s*\)\s*$/i, ''); + const outerRingText = body.split(/\)\s*,\s*\(/)[0]; + const coordinates = outerRingText + .split(',') + .map(pair => pair.trim().split(/\s+/)) + .map(parts => ({ + longitude: Number(parts[0]), + latitude: Number(parts[1]) + })) + .filter(point => Number.isFinite(point.longitude) && Number.isFinite(point.latitude)); + + if (coordinates.length < 3) { + return null; + } + + const degrees = []; + coordinates.forEach(point => { + degrees.push(point.longitude, point.latitude, 0.0); + }); + return degrees; + } + + function intervalColor(revSign) { + return revSign === 'DESC' + ? Cesium.Color.CYAN + : Cesium.Color.YELLOW; + } + + function colorForSatellite(satelliteId) { + const id = Number(satelliteId); + const seed = Number.isFinite(id) ? Math.abs(id) : 0; + const hue = ((seed * 37) % 360) / 360; + return Cesium.Color.fromHsl(hue, 0.72, 0.52, 1.0); + } + + function formatValue(value) { + return value === null || value === undefined || value === '' ? '-' : String(value).replace('T', ' '); + } + + function createRoutesDataSource(run, routes, intervals) { + const dataSource = new Cesium.CustomDataSource(dataSourceName(run.runId)); + intervals.forEach((interval, index) => { + const positions = parsePolygonWktToDegreesArray(interval.contourWkt); + if (!positions) { + console.warn('Failed to parse dynamic plan interval contour', interval); + return; + } + + const color = intervalColor(interval.revSign); + dataSource.entities.add({ + id: `dynamic_plan_interval_${run.runId}_${interval.id || index}`, + name: `Dynamic Plan interval ${interval.revSign || '-'}`, + description: [ + `Run ID: ${formatValue(run.runId)}`, + `Request ID: ${formatValue(interval.requestId || run.requestId)}`, + `Interval ID: ${formatValue(interval.intervalId)}`, + `Region ID: ${formatValue(interval.regionId)}`, + `Revolution sign: ${formatValue(interval.revSign)}`, + `Observation parameters: ${formatValue(interval.observationParametersCount)}` + ].join('
'), + polygon: { + hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions), + material: color.withAlpha(0.08), + outline: true, + outlineColor: color.withAlpha(0.95), + outlineWidth: 1.0 + }, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)), + width: 1.0, + material: color.withAlpha(0.95) + } + }); + }); + + routes.forEach((route, index) => { + const positions = parsePolygonWktToDegreesArray(route.contourWkt); + if (!positions) { + console.warn('Failed to parse dynamic plan route contour', route); + return; + } + + const color = colorForSatellite(route.satelliteId); + dataSource.entities.add({ + id: `dynamic_plan_route_${run.runId}_${route.id || index}`, + name: `Dynamic Plan route ${route.satelliteId || '-'}`, + description: [ + `Run ID: ${formatValue(run.runId)}`, + `Request ID: ${formatValue(route.requestId || run.requestId)}`, + `Satellite: ${formatValue(route.satelliteId)}`, + `Start: ${formatValue(route.startTime)}`, + `End: ${formatValue(route.endTime)}`, + `Duration: ${formatValue(route.duration)}`, + `Revolution: ${formatValue(route.revolution)}`, + `Roll: ${formatValue(route.roll)}` + ].join('
'), + polygon: { + hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions), + material: color.withAlpha(0.22), + outline: true, + outlineColor: color, + outlineWidth: 2.0 + }, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)), + width: 2.0, + material: color + } + }); + }); + return dataSource; + } + + async function requestJson(url) { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + const isJson = response.headers.get('content-type')?.includes('application/json'); + const body = isJson ? await response.json() : await response.text(); + if (!response.ok) { + if (body && typeof body === 'object') { + throw new Error(body.error || body.message || Object.values(body).join('; ')); + } + throw new Error(body || `HTTP ${response.status}`); + } + return body; + } + + async function loadRoutes(run) { + const routes = []; + let offset = 0; + while (routes.length < maxRoutesToDraw) { + const page = await requestJson( + `/api/dynamic-plan/runs/${encodeURIComponent(run.runId)}/routes?limit=${routePageLimit}&offset=${offset}` + ); + if (!Array.isArray(page) || page.length === 0) { + break; + } + routes.push(...page); + if (page.length < routePageLimit) { + break; + } + offset += routePageLimit; + } + if (run.routesCount !== null && run.routesCount > maxRoutesToDraw) { + console.warn(`Dynamic plan run ${run.runId} has ${run.routesCount} routes; only ${maxRoutesToDraw} are drawn`); + } + return routes.slice(0, maxRoutesToDraw); + } + + async function loadIntervals(run) { + const intervals = []; + let offset = 0; + while (intervals.length < maxIntervalsToDraw) { + const page = await requestJson( + `/api/dynamic-plan/runs/${encodeURIComponent(run.runId)}/intervals?limit=${intervalPageLimit}&offset=${offset}` + ); + if (!Array.isArray(page) || page.length === 0) { + break; + } + intervals.push(...page); + if (page.length < intervalPageLimit) { + break; + } + offset += intervalPageLimit; + } + if (intervals.length >= maxIntervalsToDraw) { + console.warn(`Dynamic plan run ${run.runId} has at least ${maxIntervalsToDraw} intervals; only ${maxIntervalsToDraw} are drawn`); + } + return intervals.slice(0, maxIntervalsToDraw); + } + + async function drawRun(run) { + if (!mapViewer || !run || !run.runId) { + return; + } + + removeRunDataSource(run.runId); + const [intervals, routes] = await Promise.all([ + loadIntervals(run), + loadRoutes(run) + ]); + const dataSource = createRoutesDataSource(run, routes, intervals); + await mapViewer.dataSources.add(dataSource); + if (dataSource.entities.values.length > 0) { + mapViewer.flyTo(dataSource, { duration: 1.5 }); + } + } + + function syncMap() { + if (!mapViewer) { + return; + } + + const runs = readRuns(); + const visibleIds = new Set(runs.map(run => String(run.runId))); + mapViewer.dataSources._dataSources + .filter(dataSource => dataSource.name && dataSource.name.startsWith(dataSourcePrefix)) + .forEach(dataSource => { + const runId = dataSource.name.substring(dataSourcePrefix.length); + if (!visibleIds.has(runId)) { + mapViewer.dataSources.remove(dataSource); + } + }); + + runs.forEach(run => { + const existing = mapViewer.dataSources.getByName(dataSourceName(run.runId))[0]; + if (!existing) { + drawRun(run).catch(error => console.error('Failed to draw dynamic plan run', run.runId, error)); + } + }); + } + + function initializeMap(viewer) { + mapViewer = viewer; + syncMap(); + } + + window.addEventListener('storage', event => { + if (event.key === storageKey) { + syncMap(); + } + }); + window.addEventListener('dynamic-plan-map-runs-changed', syncMap); + + window.PcpDynamicPlanMapLayers = { + initializeMap, + showRun, + hideRun, + isRunVisible, + visibleRuns: readRuns + }; +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/dynamic_plan_scripts.js b/services/pcp-ui-service/src/main/resources/static/dynamic_plan_scripts.js new file mode 100644 index 0000000..ef8dbfc --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/dynamic_plan_scripts.js @@ -0,0 +1,483 @@ +(function () { + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + const pollingDelayMs = 3000; + let pollingTimer = null; + let selectedRun = null; + + function text(value, fallback = '') { + if (value === null || value === undefined || value === '') { + return fallback; + } + return String(value); + } + + function escapeHtml(value) { + return text(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function showAlert(message, type = 'danger') { + const container = document.getElementById('dynamic-plan-alert'); + container.innerHTML = message + ? `` + : ''; + } + + function parseSatelliteIds(value) { + return String(value || '') + .split(/[,\s;]+/) + .map(item => item.trim()) + .filter(Boolean) + .map(item => Number(item)) + .filter(item => Number.isInteger(item)); + } + + function formatDuration(value) { + const ms = Number(value); + if (!Number.isFinite(ms) || ms < 0) { + return '-'; + } + if (ms < 1000) { + return `${Math.round(ms)} мс`; + } + const seconds = Math.round(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return minutes > 0 ? `${minutes} мин ${remainder} с` : `${remainder} с`; + } + + function formatDateTime(value) { + return text(value, '-').replace('T', ' '); + } + + function startOfDay(value) { + return `${value}T00:00:00`; + } + + function endOfDay(value) { + return `${value}T23:59:59`; + } + + function shortId(value) { + const fullValue = text(value, '-'); + return fullValue.length > 12 ? `${fullValue.slice(0, 8)}...${fullValue.slice(-4)}` : fullValue; + } + + function statusText(status) { + switch (status) { + case 'PENDING': + return 'В очереди'; + case 'RUNNING': + return 'Выполняется'; + case 'COMPLETED': + return 'Готово'; + case 'FAILED': + return 'Ошибка'; + default: + return text(status, '-'); + } + } + + function calculationModeText(mode) { + switch (mode) { + case 'GREEDY': + return 'Жадный'; + case 'FULL': + return 'Полный'; + default: + return text(mode, '-'); + } + } + + function revolutionModeText(mode) { + switch (mode) { + case 'ASC': + return 'Восходящий'; + case 'DESC': + return 'Нисходящий'; + case 'BOTH': + return 'Оба'; + default: + return text(mode, '-'); + } + } + + function booleanText(value) { + return value ? 'Да' : 'Нет'; + } + + function percentText(value) { + const percent = Number(value); + return Number.isFinite(percent) ? `${percent.toFixed(2)}%` : '-'; + } + + function routeIntervalText(start, end) { + if (!start && !end) { + return '-'; + } + return `${formatDateTime(start)} - ${formatDateTime(end)}`; + } + + function renderRuns(items) { + const body = document.getElementById('dynamic-plan-runs-body'); + const meta = document.getElementById('dynamic-plan-runs-meta'); + const runs = Array.isArray(items) ? items : []; + meta.textContent = runs.length > 0 ? `Показано запусков: ${runs.length}` : 'Запуски не найдены.'; + + if (runs.length === 0) { + body.innerHTML = '
Нет данных
'; + return; + } + + body.innerHTML = runs.map(run => ` + + `).join(''); + + body.querySelectorAll('[data-run-id]').forEach(button => { + button.addEventListener('click', () => openRun(button.dataset.runId)); + }); + } + + async function loadRuns() { + const payload = await requestJson('/api/dynamic-plan/runs?limit=20&offset=0', { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + renderRuns(payload); + } + + async function requestJson(url, options) { + const response = await fetch(url, options); + const isJson = response.headers.get('content-type')?.includes('application/json'); + const body = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (body && typeof body === 'object') { + throw new Error(body.error || body.message || Object.values(body).join('; ')); + } + throw new Error(body || `HTTP ${response.status}`); + } + + return body; + } + + function field(label, value) { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `; + } + + function mapControls(run) { + if (!run || run.status !== 'COMPLETED') { + return ''; + } + + return ` +
+
+
Карта
+
+
+
+ + + +
+
+ `; + } + + function updateMapControls(run) { + const showButton = document.getElementById('dynamic-plan-map-show'); + const hideButton = document.getElementById('dynamic-plan-map-hide'); + const state = document.getElementById('dynamic-plan-map-state'); + if (!showButton || !hideButton || !state || !window.PcpDynamicPlanMapLayers) { + return; + } + + const visible = window.PcpDynamicPlanMapLayers.isRunVisible(run.runId); + showButton.disabled = visible; + hideButton.disabled = !visible; + state.textContent = visible + ? 'Расчет добавлен в слой карты.' + : 'Расчет не отображается на карте.'; + } + + function attachMapControls(run) { + const showButton = document.getElementById('dynamic-plan-map-show'); + const hideButton = document.getElementById('dynamic-plan-map-hide'); + const openButton = document.getElementById('dynamic-plan-map-open'); + if (!showButton || !hideButton || !openButton || !window.PcpDynamicPlanMapLayers) { + return; + } + + showButton.addEventListener('click', () => { + window.PcpDynamicPlanMapLayers.showRun(run); + updateMapControls(run); + showAlert('Расчет добавлен в слой карты. Откройте вкладку map для просмотра.', 'success'); + }); + hideButton.addEventListener('click', () => { + window.PcpDynamicPlanMapLayers.hideRun(run.runId); + updateMapControls(run); + showAlert('Расчет скрыт с карты.', 'success'); + }); + openButton.addEventListener('click', () => { + window.location.href = '/map'; + }); + updateMapControls(run); + } + + function clearPolling() { + if (pollingTimer) { + clearTimeout(pollingTimer); + pollingTimer = null; + } + } + + function renderRun(payload) { + selectedRun = payload; + const result = document.getElementById('dynamic-plan-result'); + const meta = document.getElementById('dynamic-plan-result-meta'); + meta.textContent = `Статус запуска: ${statusText(payload.status)}`; + result.innerHTML = ` +
+ ${field('Run ID', payload.runId)} + ${field('Статус запуска', statusText(payload.status))} + ${field('Вариант расчета', calculationModeText(payload.calculationMode))} + ${field('Виток', revolutionModeText(payload.revolutionMode))} + ${field('Фильтр покрытия', booleanText(payload.filterCoveredRoutes))} + ${field('Заявка', payload.requestId)} + ${field('КА', Array.isArray(payload.satelliteIds) ? payload.satelliteIds.join(', ') : '-')} + ${field('Начало интервала', formatDateTime(payload.calculationStart))} + ${field('Конец интервала', formatDateTime(payload.calculationEnd))} + ${field('Статус результата', payload.resultStatus ?? '-')} + ${field('Маршруты', payload.routesCount ?? '-')} + ${field('Длительность', formatDuration(payload.durationMs))} +
+ ${payload.errorMessage ? `` : ''} + ${mapControls(payload)} +
Run payload
+
${escapeHtml(JSON.stringify(payload, null, 2))}
+ `; + attachMapControls(payload); + } + + function renderResult(payload, runId) { + const run = selectedRun && String(selectedRun.runId) === String(runId) + ? { + ...selectedRun, + status: 'COMPLETED', + requestId: payload.requestId ?? selectedRun.requestId, + routesCount: payload.routesCount ?? selectedRun.routesCount, + calculationMode: payload.calculationMode ?? selectedRun.calculationMode, + revolutionMode: payload.revolutionMode ?? selectedRun.revolutionMode, + filterCoveredRoutes: payload.filterCoveredRoutes ?? selectedRun.filterCoveredRoutes, + lastRouteStart: payload.lastRouteStart ?? selectedRun.lastRouteStart, + lastRouteEnd: payload.lastRouteEnd ?? selectedRun.lastRouteEnd, + coveredAreaPercent: payload.coveredAreaPercent ?? selectedRun.coveredAreaPercent + } + : { + runId, + status: 'COMPLETED', + requestId: payload.requestId, + routesCount: payload.routesCount, + calculationMode: payload.calculationMode, + revolutionMode: payload.revolutionMode, + filterCoveredRoutes: payload.filterCoveredRoutes, + lastRouteStart: payload.lastRouteStart, + lastRouteEnd: payload.lastRouteEnd, + coveredAreaPercent: payload.coveredAreaPercent + }; + selectedRun = run; + const result = document.getElementById('dynamic-plan-result'); + const meta = document.getElementById('dynamic-plan-result-meta'); + meta.textContent = `Расчет ${runId}: готов`; + result.innerHTML = ` +
+ ${field('Run ID', runId)} + ${field('Статус результата', payload.status)} + ${field('Вариант расчета', calculationModeText(payload.calculationMode))} + ${field('Виток', revolutionModeText(payload.revolutionMode))} + ${field('Фильтр покрытия', booleanText(payload.filterCoveredRoutes))} + ${field('Заявка', payload.requestId)} + ${field('КА', Array.isArray(payload.satelliteIds) ? payload.satelliteIds.join(', ') : '-')} + ${field('Отсутствующие КА', Array.isArray(payload.missingSatelliteIds) && payload.missingSatelliteIds.length ? payload.missingSatelliteIds.join(', ') : '-')} + ${field('Маршруты', payload.routesCount ?? '-')} + ${field('Последний маршрут', routeIntervalText(payload.lastRouteStart, payload.lastRouteEnd))} + ${field('Процент снятой территории', percentText(payload.coveredAreaPercent))} + ${field('Длительность', formatDuration(payload.durationMs))} +
+ ${mapControls(run)} +
Result payload
+
${escapeHtml(JSON.stringify(payload, null, 2))}
+ `; + attachMapControls(run); + } + + function setSubmitLoading(isLoading) { + const button = document.getElementById('dynamic-plan-submit'); + if (!button.dataset.defaultText) { + button.dataset.defaultText = button.innerHTML; + } + button.disabled = isLoading; + button.innerHTML = isLoading + ? 'Запуск' + : button.dataset.defaultText; + } + + async function loadResult(runId) { + const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}/result`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + renderResult(payload, runId); + showAlert('Расчет завершен, результат получен', 'success'); + } + + async function openRun(runId) { + if (!runId) { + return; + } + + clearPolling(); + showAlert(''); + try { + const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + renderRun(payload); + + if (payload.status === 'COMPLETED') { + await loadResult(runId); + return; + } + if (payload.status === 'FAILED') { + showAlert(`Расчет завершился с ошибкой: ${payload.errorMessage || 'неизвестная ошибка'}`); + return; + } + + pollingTimer = setTimeout(() => pollRun(runId), pollingDelayMs); + } catch (error) { + showAlert(`Ошибка получения статуса расчета: ${error.message}`); + } + } + + async function pollRun(runId) { + try { + const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + renderRun(payload); + + if (payload.status === 'COMPLETED') { + clearPolling(); + await loadResult(runId); + loadRuns().catch(() => undefined); + return; + } + if (payload.status === 'FAILED') { + clearPolling(); + loadRuns().catch(() => undefined); + showAlert(`Расчет завершился с ошибкой: ${payload.errorMessage || 'неизвестная ошибка'}`); + return; + } + + pollingTimer = setTimeout(() => pollRun(runId), pollingDelayMs); + } catch (error) { + clearPolling(); + showAlert(`Ошибка получения статуса расчета: ${error.message}`); + } + } + + async function submitForm(event) { + event.preventDefault(); + const requestId = document.getElementById('dynamic-plan-request-id').value.trim(); + const satelliteIds = parseSatelliteIds(document.getElementById('dynamic-plan-satellite-ids').value); + const calculationMode = document.querySelector('input[name="dynamic-plan-mode"]:checked')?.value || 'FULL'; + const revolutionMode = document.querySelector('input[name="dynamic-plan-revolution-mode"]:checked')?.value || 'BOTH'; + const filterCoveredRoutes = document.getElementById('dynamic-plan-filter-covered-routes').checked; + const calculationStartDate = document.getElementById('dynamic-plan-interval-start').value; + const calculationEndDate = document.getElementById('dynamic-plan-interval-end').value; + + if (!uuidPattern.test(requestId)) { + showAlert('Укажите корректный UUID заявки'); + return; + } + if (satelliteIds.length === 0) { + showAlert('Укажите хотя бы один идентификатор КА'); + return; + } + if (!calculationStartDate || !calculationEndDate) { + showAlert('Укажите дату начала и дату конца интервала расчета'); + return; + } + if (calculationStartDate > calculationEndDate) { + showAlert('Дата начала интервала расчета должна быть не позже даты окончания'); + return; + } + + const calculationStart = startOfDay(calculationStartDate); + const calculationEnd = endOfDay(calculationEndDate); + + setSubmitLoading(true); + clearPolling(); + showAlert(''); + try { + const payload = await requestJson('/api/dynamic-plan/calc', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + requestId, + satelliteIds, + calculationMode, + revolutionMode, + filterCoveredRoutes, + calculationStart, + calculationEnd + }) + }); + renderRun(payload); + showAlert('Расчет поставлен в очередь', 'success'); + loadRuns().catch(() => undefined); + pollingTimer = setTimeout(() => pollRun(payload.runId), pollingDelayMs); + } catch (error) { + showAlert(`Ошибка запуска расчета: ${error.message}`); + } finally { + setSubmitLoading(false); + } + } + + document.getElementById('dynamic-plan-form').addEventListener('submit', submitForm); + document.getElementById('dynamic-plan-runs-refresh').addEventListener('click', () => { + loadRuns().catch(error => showAlert(`Ошибка обновления истории запусков: ${error.message}`)); + }); + loadRuns().catch(error => showAlert(`Ошибка загрузки истории запусков: ${error.message}`)); +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/group_state_scripts.js b/services/pcp-ui-service/src/main/resources/static/group_state_scripts.js new file mode 100644 index 0000000..fb1f3aa --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/group_state_scripts.js @@ -0,0 +1,327 @@ +(function () { + const pageRoot = document.getElementById('group-state-page'); + if (!pageRoot) { + return; + } + + const apiBase = '/api/catalog/group-states'; + const palette = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#17becf', '#bcbd22']; + + const state = { + groups: [], + selectedGroupId: null, + details: null + }; + + function showAlert(message, type = 'danger') { + const container = document.getElementById('group-state-alert'); + if (!message) { + container.innerHTML = ''; + return; + } + container.innerHTML = ``; + } + + async function requestJson(url, options = {}) { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json' + }, + ...options + }); + + if (response.status === 204) { + return null; + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const payload = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (payload && typeof payload === 'object') { + if (payload.error) { + throw new Error(payload.error); + } + throw new Error(Object.values(payload).join('; ')); + } + throw new Error(payload || `HTTP ${response.status}`); + } + + return payload; + } + + function formatDateTimeLabel(value) { + return value ? value.replace('T', ' ') : '-'; + } + + function formatInputDateTime(value) { + if (!value) { + return ''; + } + return value.length >= 19 ? value.slice(0, 19) : value; + } + + function formatAngle(value) { + return `${value.toFixed(2)}deg`; + } + + function renderGroupList() { + const tableBody = document.getElementById('group-state-list'); + tableBody.innerHTML = ''; + + if (!state.groups.length) { + tableBody.innerHTML = 'Группировки не найдены'; + return; + } + + state.groups.forEach(group => { + const tr = document.createElement('tr'); + if (group.groupId === state.selectedGroupId) { + tr.classList.add('table-active'); + } + const interval = group.availableInterval + ? `${formatDateTimeLabel(group.availableInterval.timeStart)} - ${formatDateTimeLabel(group.availableInterval.timeStop)}` + : 'Нет пересечения'; + tr.innerHTML = ` + +
${group.groupName}
+
#${group.groupId}
+ + ${group.satelliteCount} + ${interval} + `; + tr.addEventListener('click', () => selectGroup(group.groupId)); + tableBody.appendChild(tr); + }); + } + + function renderTable(details) { + const empty = document.getElementById('group-state-table-empty'); + const wrap = document.getElementById('group-state-table-wrap'); + + if (!details || !details.availableInterval || !details.satellites.length) { + empty.classList.remove('d-none'); + wrap.classList.add('d-none'); + wrap.innerHTML = ''; + return; + } + + const headerCells = details.satellites + .map(satellite => `${escapeHtml(satellite.name)}
${escapeHtml(satellite.code || String(satellite.satelliteId))}
`) + .join(''); + const rows = details.matrix.map(row => { + const satellite = details.satellites.find(item => item.satelliteId === row.satelliteId); + const cells = row.cells.map(cell => { + const isDiagonal = cell.rowSatelliteId === cell.columnSatelliteId; + if (isDiagonal) { + return '-'; + } + return ` + +
+
dOMEGAB: ${formatAngle(cell.deltaOmegabDeg)}
+
du: ${formatAngle(cell.deltaUDeg)}
+
+ + `; + }).join(''); + + return ` + + + ${escapeHtml(satellite?.name || String(row.satelliteId))} +
${escapeHtml(satellite?.code || String(row.satelliteId))}
+ + ${cells} + + `; + }).join(''); + + wrap.innerHTML = ` + + + + + ${headerCells} + + + ${rows} +
Спутник
+ `; + empty.classList.add('d-none'); + wrap.classList.remove('d-none'); + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function polarPoint(angleDeg, radius, center) { + const angleRad = (angleDeg - 90) * Math.PI / 180; + return { + x: center + Math.cos(angleRad) * radius, + y: center + Math.sin(angleRad) * radius + }; + } + + function renderPolarChart(title, satellites, key, valueLabel) { + const size = 320; + const center = size / 2; + const radius = 112; + const innerRadius = 92; + + const ticks = satellites.map((satellite, index) => { + const color = palette[index % palette.length]; + const start = polarPoint(satellite[key], innerRadius, center); + const end = polarPoint(satellite[key], radius, center); + return ``; + }).join(''); + + const legend = satellites.map((satellite, index) => { + const color = palette[index % palette.length]; + return ` +
+ + ${escapeHtml(satellite.name)}: ${formatAngle(satellite[key])} +
+ `; + }).join(''); + + return ` +
+
${title}
+ + + + ${ticks} + ${escapeHtml(valueLabel)} + ${satellites.length} + +
${legend}
+
+ `; + } + + function renderCharts(details) { + const empty = document.getElementById('group-state-charts-empty'); + const container = document.getElementById('group-state-charts'); + + if (!details || !details.availableInterval || !details.satellites.length) { + empty.classList.remove('d-none'); + container.classList.add('d-none'); + container.innerHTML = ''; + return; + } + + container.innerHTML = [ + renderPolarChart('ВУЗ орбиты', details.satellites, 'omegabDeg', 'OMEGAB'), + renderPolarChart('Аргумент широты', details.satellites, 'uDeg', 'U') + ].join(''); + empty.classList.add('d-none'); + container.classList.remove('d-none'); + } + + function renderSummary(details) { + const summary = document.getElementById('group-state-summary'); + const timeInput = document.getElementById('group-state-time'); + const applyButton = document.getElementById('group-state-apply'); + + if (!details) { + summary.textContent = 'Выберите группировку слева.'; + timeInput.value = ''; + timeInput.min = ''; + timeInput.max = ''; + timeInput.disabled = true; + applyButton.disabled = true; + return; + } + + if (!details.availableInterval) { + summary.textContent = `${details.groupName}: нет общего доступного интервала расчета по всем спутникам ОГ.`; + timeInput.value = ''; + timeInput.min = ''; + timeInput.max = ''; + timeInput.disabled = true; + applyButton.disabled = true; + return; + } + + summary.innerHTML = ` +
${escapeHtml(details.groupName)}
+
Доступный интервал: ${formatDateTimeLabel(details.availableInterval.timeStart)} - ${formatDateTimeLabel(details.availableInterval.timeStop)}
+ `; + timeInput.disabled = false; + applyButton.disabled = false; + timeInput.min = formatInputDateTime(details.availableInterval.timeStart); + timeInput.max = formatInputDateTime(details.availableInterval.timeStop); + timeInput.value = formatInputDateTime(details.calculationTime || details.availableInterval.timeStart); + } + + function renderDetails(details) { + state.details = details; + renderSummary(details); + renderTable(details); + renderCharts(details); + } + + async function selectGroup(groupId, requestedTime = null) { + state.selectedGroupId = groupId; + renderGroupList(); + + const query = requestedTime ? `?time=${encodeURIComponent(requestedTime)}` : ''; + const details = await requestJson(`${apiBase}/${groupId}${query}`); + renderDetails(details); + } + + async function loadGroups() { + state.groups = await requestJson(apiBase); + renderGroupList(); + + if (!state.groups.length) { + renderDetails(null); + return; + } + + const selectedId = state.groups.some(group => group.groupId === state.selectedGroupId) + ? state.selectedGroupId + : state.groups[0].groupId; + await selectGroup(selectedId); + } + + document.getElementById('group-state-refresh').addEventListener('click', async () => { + try { + showAlert(''); + await loadGroups(); + } catch (error) { + showAlert(error.message); + } + }); + + document.getElementById('group-state-apply').addEventListener('click', async () => { + if (!state.selectedGroupId) { + showAlert('Сначала выберите группировку'); + return; + } + + const time = document.getElementById('group-state-time').value; + if (!time) { + showAlert('Выберите момент времени'); + return; + } + + try { + showAlert(''); + await selectGroup(state.selectedGroupId, time); + } catch (error) { + showAlert(error.message); + } + }); + + renderDetails(null); + loadGroups().catch(error => showAlert(error.message)); +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/model/CloudSat.glb b/services/pcp-ui-service/src/main/resources/static/model/CloudSat.glb new file mode 100644 index 0000000..50385c5 Binary files /dev/null and b/services/pcp-ui-service/src/main/resources/static/model/CloudSat.glb differ diff --git a/services/pcp-ui-service/src/main/resources/static/model/Duck.glb b/services/pcp-ui-service/src/main/resources/static/model/Duck.glb new file mode 100644 index 0000000..217170d Binary files /dev/null and b/services/pcp-ui-service/src/main/resources/static/model/Duck.glb differ diff --git a/services/pcp-ui-service/src/main/resources/static/model/radarSat.glb b/services/pcp-ui-service/src/main/resources/static/model/radarSat.glb new file mode 100644 index 0000000..3791695 Binary files /dev/null and b/services/pcp-ui-service/src/main/resources/static/model/radarSat.glb differ diff --git a/services/pcp-ui-service/src/main/resources/static/model/sat.glb b/services/pcp-ui-service/src/main/resources/static/model/sat.glb new file mode 100644 index 0000000..051b10d Binary files /dev/null and b/services/pcp-ui-service/src/main/resources/static/model/sat.glb differ diff --git a/services/pcp-ui-service/src/main/resources/static/model/station.glb b/services/pcp-ui-service/src/main/resources/static/model/station.glb new file mode 100644 index 0000000..9c95637 Binary files /dev/null and b/services/pcp-ui-service/src/main/resources/static/model/station.glb differ 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 new file mode 100644 index 0000000..4e56978 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/requests_scripts.js @@ -0,0 +1,1591 @@ + + + + + function drawReq(show, req){ + if (req){ + if (!show){ + var name = 'req_'+req.requestId + while (viewer.dataSources.remove(viewer.dataSources.getByName(name)[0])) {} + } else{ + + var u = '/requests/req-with-cells/'+req.requestId + fetch(u, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }) + .then((response)=>response.json()) + .then((json) =>{ + viewer.dataSources.add(Cesium.CzmlDataSource.load(json)); + }) + } + } + } + + + function drawOrbit(show, req, startInput, endInput){ + if (req){ + if (!show){ + var name = 'sat_'+req.noradId + while (viewer.dataSources.remove(viewer.dataSources.getByName(name)[0])) {} + checkModels(); + } else{ + + + const inputOrb = document.getElementById('toggleOrbit'); + let valueOrb = inputOrb.checked; + const inputFl = document.getElementById('toggleFL'); + let valueFl = inputFl.checked; + const inputSw = document.getElementById('toggleSwath'); + let valueSw = inputSw.checked; + + var sceneMode = (viewer.scene.mode == Cesium.SceneMode.SCENE2D); + var u = '/requests/sat/'+req.noradId+ + '?view='+sceneMode+'&orbit='+valueOrb+ + '&sw='+valueSw+ + '&fl='+valueFl + if (startInput && endInput) { + u += '&tn=' + encodeURIComponent(startInput) + '&tk=' + encodeURIComponent(endInput) + } + + console.log(u) + + fetch(u, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }) + .then((response)=>response.json()) + .then((json) =>{ + console.log(json) + viewer.dataSources.add(Cesium.CzmlDataSource.load(json)); + checkModels(); + }) + } + } + } + + + function drawSt(show, req){ + if (req){ + if (!show){ + var name = 'station_'+req.id + while (viewer.dataSources.remove(viewer.dataSources.getByName(name)[0])) {} + checkModels(); + } else{ + var sceneMode = (viewer.scene.mode == Cesium.SceneMode.SCENE2D); + var u = '/requests/station/'+req.id+'?view='+sceneMode + fetch(u, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }) + .then(async (response) => { + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : []; + } catch (error) { + payload = { message: text || `HTTP error! status: ${response.status}` }; + } + if (!response.ok) { + throw new Error(payload.error || payload.message || `HTTP error! status: ${response.status}`); + } + return payload; + }) + .then((json) =>{ + if (!Array.isArray(json) || !json[0] || json[0].id !== 'document') { + throw new Error('Некорректный CZML для плана спутника'); + } + viewer.dataSources.add(Cesium.CzmlDataSource.load(json)); + checkModels(); + }) + .catch(error => { + console.error('Ошибка при загрузке плана спутника:', error); + alert(error.message); + }) + } + } + } + + + function drawSat(show, req, startInput, endInput, runId){ + if (req){ + if (!show){ + var name = 'plan_'+req.noradId + while (viewer.dataSources.remove(viewer.dataSources.getByName(name)[0])) {} + checkModels(); + } else{ + + // const url = `/requests/req-with-covs/${requestId}?tn=${startInput}&tk=${endInput}&cov=`+cover+sign; + let u = '/requests/plan/'+req.noradId+'?tn='+startInput+'&tk='+endInput + if (runId) { + u += '&runId=' + encodeURIComponent(runId) + } + console.log(u) + fetch(u, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }) + .then((response)=>response.json()) + .then((json) =>{ + viewer.dataSources.add(Cesium.CzmlDataSource.load(json)); + checkModels(); + }) + } + } + } + + // выбрать все НИП/ППИ + function selectAllRequests(){ + $('input:checkbox[name="request"]:visible:not(:checked)').each(function(){$(this).trigger('click')}); + } + + // скрыть все НИП/ППИ + function deselectAllRequests(){ + $('input:checkbox[name="request"]:checked').each(function(){$(this).trigger('click')}); + } + + // Функция для отображения тепловой карты + function showHeatmap(importance, countLat, countLong) { + if (!window.viewer) return; + hideHeatmap(); + var params = new URLSearchParams({ importance: importance }); + if (countLat) { + params.append('countLat', countLat); + } + if (countLong) { + params.append('countLong', countLong); + } + var u = '/requests?' + params.toString() + fetch(u, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }) + .then((response)=>response.json()) + .then((json) =>{ + viewer.dataSources.add(Cesium.CzmlDataSource.load(json)); + }) + } + + // Функция для скрытия тепловой карты + function hideHeatmap() { + if (!window.viewer) return; + // Удаляем слой тепловой карты, если он существует + while (viewer.dataSources.remove(viewer.dataSources.getByName('heatMap')[0])) {} + } + + + function useSceneMode(sceneMode){ + if (sceneMode == Cesium.SceneMode.SCENE2D){ + console.log('2D') + } else + console.log('3D') + } + + + + + + function checkModels(){ + + var sceneMode = (viewer.scene.mode == Cesium.SceneMode.SCENE2D); + + viewer.dataSources._dataSources.forEach(dataSource => { + dataSource._entityCollection._entities._array.forEach( entity => { + if (entity.id.includes('station_')){ + entity.model.show=!sceneMode + entity.billboard.show=sceneMode + } else if (entity.id.includes('orbit_')){ + entity.model.show=!sceneMode + entity.point.show=sceneMode + } + }) + + + + }); + + } + + function handleOrbits(show){ + + viewer.dataSources._dataSources.forEach(dataSource => { + dataSource._entityCollection._entities._array.forEach( entity => { + if (entity.id.includes('orbit_')){ + entity.path.show=show + } + }) + }); + } + + function handleSw(show){ + viewer.dataSources._dataSources.forEach(dataSource => { + dataSource._entityCollection._entities._array.forEach( entity => { + if (entity.id.includes('swath_')){ + entity.path.show=show + } + }) + }); + } + + + function handleFl(show){ + viewer.dataSources._dataSources.forEach(dataSource => { + dataSource._entityCollection._entities._array.forEach( entity => { + if (entity.id.includes('flight_line_')){ + entity.path.show=show + entity.point.show=show + } + }) + }); + } + + + + + + + +// Глобальные переменные для хранения данных слотов +let currentSlotsData = []; +let currentRequestId = null; +let currentSlotsContext = null; +let slotsSortConfig = { + column: 'tn', + direction: 'asc' +}; + +function removeDataSourceByName(dataSourceName) { + let dataSource = viewer.dataSources.getByName(dataSourceName)[0]; + while (dataSource && viewer.dataSources.remove(dataSource)) { + dataSource = viewer.dataSources.getByName(dataSourceName)[0]; + } +} + +function getCurrentSlotsDataSource() { + if (!currentSlotsContext || !currentSlotsContext.dataSourceName) { + return null; + } + + return viewer.dataSources.getByName(currentSlotsContext.dataSourceName)[0] || null; +} + +function getCoverageSchemeDataSourceName(requestId) { + return `req_cov_scheme_${requestId}`; +} + +function setSlotsContext(context) { + currentSlotsContext = context; + updateSlotsActionButtons(); +} + +function updateSlotsActionButtons() { + const isRequestContext = currentSlotsContext && currentSlotsContext.type === 'request'; + const bookButton = document.getElementById('bookSlotsBtn'); + const cancelButton = document.getElementById('cancelBookingBtn'); + + if (bookButton) { + bookButton.style.display = isRequestContext ? '' : 'none'; + } + if (cancelButton) { + cancelButton.style.display = isRequestContext ? '' : 'none'; + } +} + +function getSatelliteBookedSlotsDataSourceName(satelliteId) { + return `sat_booked_${satelliteId}`; +} + +function getSatelliteSlotsDataSourceName(satelliteId) { + return `sat_slots_${satelliteId}`; +} + +function buildSlotEntityId(slot, index) { + if (slot.entityId) { + return slot.entityId; + } + + const satelliteId = slot.satelliteId ?? 'unknown'; + const cycle = slot.cycle ?? slot.revolution ?? 0; + const slotNumber = slot.slotNumber + ?? (Array.isArray(slot.slotIds) && slot.slotIds.length > 0 ? slot.slotIds[0] : index); + + return `slot_${satelliteId}_${cycle}_${slotNumber}`; +} + +function normalizeRequestSlots(slots) { + return (slots || []).map((slot, index) => ({ + ...slot, + index: index, + entityId: buildSlotEntityId(slot, index) + })); +} + +function normalizeSatelliteBookedSlots(slots) { + return (slots || []).map((slot, index) => ({ + ...slot, + cycle: slot.revolution ?? 0, + slotNumber: Array.isArray(slot.slotIds) && slot.slotIds.length > 0 ? slot.slotIds[0] : index + 1, + state: 'BOOKED', + index: index, + entityId: buildSlotEntityId({ + ...slot, + cycle: slot.revolution ?? 0, + slotNumber: Array.isArray(slot.slotIds) && slot.slotIds.length > 0 ? slot.slotIds[0] : index + 1 + }, index) + })); +} + +function normalizeSatelliteSlots(slots) { + return (slots || []).map((slot, index) => ({ + ...slot, + index: index, + entityId: buildSlotEntityId(slot, index) + })); +} + +function normalizeCoverageSchemeModes(modes) { + return (modes || []).map((mode, index) => ({ + ...mode, + index: index, + sequence: mode.sequence ?? (index + 1), + cycle: mode.cycle ?? mode.sequence ?? (index + 1), + tn: mode.tn ?? mode.timeStart, + tk: mode.tk ?? mode.timeEnd, + roll: mode.roll ?? mode.rollDegrees, + contour: mode.contour ?? mode.contourWkt, + entityId: mode.entityId ?? `coverage_mode_${mode.satelliteId ?? 'unknown'}_${mode.sequence ?? index + 1}` + })); +} + +function parsePolygonWktToDegreesArray(contourWkt) { + if (typeof contourWkt !== 'string') { + return null; + } + + const trimmed = contourWkt.trim(); + if (!trimmed.toUpperCase().startsWith('POLYGON')) { + return null; + } + + const body = trimmed + .replace(/^POLYGON\s*\(\(/i, '') + .replace(/\)\)\s*$/i, ''); + const outerRingText = body.split('),(')[0]; + + const coordinates = outerRingText + .split(',') + .map(pair => pair.trim().split(/\s+/)) + .map(parts => ({ + longitude: Number(parts[0]), + latitude: Number(parts[1]) + })) + .filter(point => Number.isFinite(point.longitude) && Number.isFinite(point.latitude)); + + if (coordinates.length < 3) { + return null; + } + + const degrees = []; + coordinates.forEach(point => { + degrees.push(point.longitude, point.latitude, 0.0); + }); + + return degrees; +} + +function closeDegreesArrayHeights(positions) { + if (!Array.isArray(positions) || positions.length < 9) { + return positions; + } + + const first = positions.slice(0, 3); + const last = positions.slice(-3); + const isClosed = first.length === last.length && first.every((value, index) => value === last[index]); + + return isClosed ? positions : positions.concat(first); +} + +function getSatelliteCesiumColor(satellite) { + const red = Number(satellite && satellite.red); + const green = Number(satellite && satellite.green); + const blue = Number(satellite && satellite.blue); + + return Cesium.Color.fromBytes( + Number.isFinite(red) ? red : 255, + Number.isFinite(green) ? green : 140, + Number.isFinite(blue) ? blue : 0, + 255 + ); +} + +function createSatelliteBookedSlotsDataSource(dataSourceName, satellite, slots) { + const dataSource = new Cesium.CustomDataSource(dataSourceName); + const color = getSatelliteCesiumColor(satellite); + + normalizeSatelliteBookedSlots(slots).forEach(slot => { + const positions = parsePolygonWktToDegreesArray(slot.contour); + if (!positions) { + console.warn('Не удалось разобрать контур забронированного слота', slot); + return; + } + + dataSource.entities.add({ + id: slot.entityId, + name: `Забронированный слот КА ${slot.satelliteId}`, + description: [ + `Спутник: ${slot.satelliteId ?? '-'}`, + `Начало: ${formatDateTime(slot.tn)}`, + `Окончание: ${formatDateTime(slot.tk)}`, + `Длительность: ${formatDuration(calculateDuration(slot.tn, slot.tk))}`, + `Виток: ${slot.revolution ?? '-'}`, + `Слоты: ${(slot.slotIds || []).join(', ') || '-'}` + ].join('
'), + polygon: { + hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions), + material: color.withAlpha(0.28), + outline: true, + outlineColor: color, + outlineWidth: 2.0 + }, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)), + width: 2.0, + material: color + } + }); + }); + + return dataSource; +} + +function createSatelliteSlotsDataSource(dataSourceName, satellite, slots) { + const dataSource = new Cesium.CustomDataSource(dataSourceName); + const color = getSatelliteCesiumColor(satellite); + + normalizeSatelliteSlots(slots).forEach(slot => { + const positions = parsePolygonWktToDegreesArray(slot.contour); + if (!positions) { + console.warn('Не удалось разобрать контур слота', slot); + return; + } + + dataSource.entities.add({ + id: slot.entityId, + name: `Слот КА ${slot.satelliteId}`, + description: [ + `Спутник: ${slot.satelliteId ?? '-'}`, + `Цикл: ${slot.cycle ?? '-'}`, + `Начало: ${formatDateTime(slot.tn)}`, + `Окончание: ${formatDateTime(slot.tk)}`, + `Длительность: ${formatDuration(calculateDuration(slot.tn, slot.tk))}`, + `Виток: ${slot.revolution ?? '-'}`, + `Номер слота: ${slot.slotNumber ?? '-'}` + ].join('
'), + polygon: { + hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions), + material: color.withAlpha(0.18), + outline: true, + outlineColor: color, + outlineWidth: 2.0 + }, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)), + width: 2.0, + material: color + } + }); + }); + + return dataSource; +} + +function processAndDisplaySatelliteBookedSlots(slots, satellite) { + currentSlotsData = normalizeSatelliteBookedSlots(slots); + currentRequestId = null; + setSlotsContext({ + type: 'satellite-booked', + key: String(satellite.noradId), + dataSourceName: getSatelliteBookedSlotsDataSourceName(satellite.noradId) + }); + + document.getElementById('slotsTitle').textContent = + `Забронированные слоты спутника "${satellite.name}" (${currentSlotsData.length} интервалов)`; + document.getElementById('slotsContainer').style.display = 'block'; + + if (currentSlotsData.length === 0) { + showEmptySlotsMessage('Для выбранного временного диапазона забронированные слоты не найдены'); + updateSlotsSummary(); + return; + } + + renderSlotsTable(); + updateSlotsSummary(); +} + +function processAndDisplaySatelliteSlots(slots, satellite) { + currentSlotsData = normalizeSatelliteSlots(slots); + currentRequestId = null; + setSlotsContext({ + type: 'satellite-slots', + key: String(satellite.noradId), + dataSourceName: getSatelliteSlotsDataSourceName(satellite.noradId) + }); + + document.getElementById('slotsTitle').textContent = + `Слоты спутника "${satellite.name}" (${currentSlotsData.length} интервалов)`; + document.getElementById('slotsContainer').style.display = 'block'; + + if (currentSlotsData.length === 0) { + showEmptySlotsMessage('Для выбранного временного диапазона слоты не найдены'); + updateSlotsSummary(); + return; + } + + updateSlotsTableHeaders(); + renderSlotsTable(); + updateSlotsSummary(); +} + +function drawSatelliteBookedSlots(show, satellite, startInput, endInput) { + if (!satellite) { + return; + } + + const dataSourceName = getSatelliteBookedSlotsDataSourceName(satellite.noradId); + + if (!show) { + removeDataSourceByName(dataSourceName); + + if (currentSlotsContext + && currentSlotsContext.type === 'satellite-booked' + && currentSlotsContext.key === String(satellite.noradId)) { + clearSlotsTable(); + } + return; + } + + const params = new URLSearchParams({ + timeStart: startInput, + timeStop: endInput + }); + + fetch(`/api/satellites/${satellite.noradId}/booked-slots?${params.toString()}`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + .then(async response => { + if (!response.ok) { + const rawError = await response.text(); + let errorMessage = rawError; + try { + const parsedError = JSON.parse(rawError); + errorMessage = parsedError.error || parsedError.message || rawError; + } catch (_) {} + throw new Error(`HTTP error! status: ${response.status} message: ${errorMessage}`); + } + return response.json(); + }) + .then(slots => { + removeDataSourceByName(dataSourceName); + viewer.dataSources.add(createSatelliteBookedSlotsDataSource(dataSourceName, satellite, slots)); + processAndDisplaySatelliteBookedSlots(slots, satellite); + }) + .catch(error => { + console.error('Ошибка при загрузке забронированных слотов спутника:', error); + alert(error.message); + }); +} + +function drawSatelliteSlots(show, satellite, startInput, endInput) { + if (!satellite) { + return; + } + + const dataSourceName = getSatelliteSlotsDataSourceName(satellite.noradId); + + if (!show) { + removeDataSourceByName(dataSourceName); + + if (currentSlotsContext + && currentSlotsContext.type === 'satellite-slots' + && currentSlotsContext.key === String(satellite.noradId)) { + clearSlotsTable(); + } + return; + } + + const params = new URLSearchParams({ + timeStart: startInput, + timeStop: endInput + }); + + fetch(`/api/satellites/${satellite.noradId}/slots?${params.toString()}`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + .then(async response => { + if (!response.ok) { + const rawError = await response.text(); + let errorMessage = rawError; + try { + const parsedError = JSON.parse(rawError); + errorMessage = parsedError.error || parsedError.message || rawError; + } catch (_) {} + throw new Error(`HTTP error! status: ${response.status} message: ${errorMessage}`); + } + return response.json(); + }) + .then(slots => { + removeDataSourceByName(dataSourceName); + viewer.dataSources.add(createSatelliteSlotsDataSource(dataSourceName, satellite, slots)); + processAndDisplaySatelliteSlots(slots, satellite); + }) + .catch(error => { + console.error('Ошибка при загрузке слотов спутника:', error); + alert(error.message); + }); +} + +function drawReqCov(show, req, startInput, endInput, node, cover, satellites, sun) { + if (req) { + const requestId = req.requestId; + const dataSourceName = 'req_cov_' + requestId; + + if (!show) { + // Удаляем данные покрытия с карты + removeDataSourceByName(dataSourceName); + + // Если это текущая отображаемая заявка, очищаем таблицу слотов + if (currentRequestId === requestId) { + clearSlotsTable(); + } + } else { + + + const params = new URLSearchParams({ + tn: startInput, + tk: endInput, + cov: cover, + sun: sun + }); + + if (node == 1) { + params.append('sign', 'ASC'); + } else if (node == 2) { + params.append('sign', 'DESC'); + } + + if (Array.isArray(satellites)) { + satellites.forEach(satelliteId => params.append('satellites', satelliteId)); + } + + params.append('sun', sun) + const url = `/requests/req-with-covs/${requestId}?${params.toString()}`; + console.log('Запрос покрытия:', url); + + fetch(url, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }) + .then(async response => { + if (!response.ok) { + const rawError = await response.text(); + let errorMessage = rawError; + try { + const parsedError = JSON.parse(rawError); + errorMessage = parsedError.error || parsedError.message || rawError; + } catch (_) {} + throw new Error(`HTTP error! status: ${response.status} message: ${errorMessage}`); + } + return response.json(); + }) + .then(json => { + // Отображаем покрытие на карте + if (json.cov && json.cov.length > 0) { + viewer.dataSources.add(Cesium.CzmlDataSource.load(json.cov)); + } + + // Обрабатываем и отображаем слоты в таблице + if (json.slots && json.slots.length > 0) { + processAndDisplaySlots(json.slots, req); + } else { + showEmptySlotsMessage('Нет интервалов покрытия для выбранного временного диапазона'); + } + }) + .catch(error => { + console.error('Ошибка при загрузке данных покрытия:', error); + alert(error.message); + }); + } + } +} + +function drawReqCoverageScheme(show, req, startInput, endInput, satellites, options) { + if (!req) { + return; + } + + const requestId = req.requestId; + const dataSourceName = getCoverageSchemeDataSourceName(requestId); + + if (!show) { + removeDataSourceByName(dataSourceName); + + if (currentSlotsContext + && currentSlotsContext.type === 'coverage-scheme' + && currentSlotsContext.key === requestId) { + clearSlotsTable(); + } + return; + } + + if (!Array.isArray(satellites) || satellites.length === 0) { + alert('Для расчета схемы покрытия выберите хотя бы один спутник'); + return; + } + + const params = new URLSearchParams({ + tn: startInput, + tk: endInput + }); + satellites.forEach(satelliteId => params.append('satellites', satelliteId)); + if (options) { + if (options.rollStepDegrees !== null && options.rollStepDegrees !== undefined) { + params.append('rollStepDegrees', options.rollStepDegrees); + } + if (options.minimumTechnologyOverlap !== null && options.minimumTechnologyOverlap !== undefined) { + params.append('minimumTechnologyOverlap', options.minimumTechnologyOverlap); + } + if (options.strictContinuousCoverage !== null && options.strictContinuousCoverage !== undefined) { + params.append('strictContinuousCoverage', options.strictContinuousCoverage); + } + if (options.allowPartialCoverage !== null && options.allowPartialCoverage !== undefined) { + params.append('allowPartialCoverage', options.allowPartialCoverage); + } + if (options.orientationToleranceDegrees !== null && options.orientationToleranceDegrees !== undefined) { + params.append('orientationToleranceDegrees', options.orientationToleranceDegrees); + } + if (options.includeDebugInfo !== null && options.includeDebugInfo !== undefined) { + params.append('includeDebugInfo', options.includeDebugInfo); + } + } + + fetch(`/requests/req-with-coverage-scheme/${requestId}?${params.toString()}`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(async response => { + if (!response.ok) { + const rawError = await response.text(); + let errorMessage = rawError; + try { + const parsedError = JSON.parse(rawError); + errorMessage = parsedError.error || parsedError.message || rawError; + } catch (_) {} + throw new Error(`HTTP error! status: ${response.status} message: ${errorMessage}`); + } + return response.json(); + }) + .then(json => { + removeDataSourceByName(dataSourceName); + + if (json.cov && json.cov.length > 0) { + viewer.dataSources.add(Cesium.CzmlDataSource.load(json.cov)); + } + + if (json.slots && json.slots.length > 0) { + processAndDisplayCoverageSchemeModes(json.slots, req); + } else { + document.getElementById('slotsTitle').textContent = + `Схема покрытия заявки "${req.name}"`; + setSlotsContext({ + type: 'coverage-scheme', + key: requestId, + dataSourceName: dataSourceName + }); + updateSlotsTableHeaders(); + showEmptySlotsMessage('Для выбранного временного диапазона режимы схемы покрытия не найдены'); + } + }) + .catch(error => { + console.error('Ошибка при загрузке схемы покрытия:', error); + alert(error.message); + }); +} + +function getCoverageSchemeRequestOptions() { + const parseOptionalNumber = (elementId) => { + const input = document.getElementById(elementId); + if (!input) { + return null; + } + + const rawValue = String(input.value ?? '').trim(); + if (rawValue === '') { + return null; + } + + const parsedValue = Number.parseFloat(rawValue); + return Number.isFinite(parsedValue) ? parsedValue : null; + }; + + return { + rollStepDegrees: parseOptionalNumber('coverageSchemeRollStep'), + minimumTechnologyOverlap: parseOptionalNumber('coverageSchemeMinOverlap'), + orientationToleranceDegrees: parseOptionalNumber('coverageSchemeOrientationTolerance'), + strictContinuousCoverage: document.getElementById('coverageSchemeStrictContinuous')?.checked ?? true, + allowPartialCoverage: document.getElementById('coverageSchemeAllowPartial')?.checked ?? false, + includeDebugInfo: document.getElementById('coverageSchemeIncludeDebug')?.checked ?? true + }; +} + +// Обработка и отображение слотов +function processAndDisplaySlots(slots, request) { + currentSlotsData = normalizeRequestSlots(slots); + currentRequestId = request.requestId; + setSlotsContext({ + type: 'request', + key: request.requestId, + dataSourceName: 'req_cov_' + request.requestId + }); + + // Обновляем заголовок + document.getElementById('slotsTitle').textContent = + `Интервалы покрытия заявки "${request.name}" (${currentSlotsData.length} интервалов)`; + updateSlotsTableHeaders(); + + // Показываем контейнер слотов + document.getElementById('slotsContainer').style.display = 'block'; + + // Рендерим таблицу + renderSlotsTable(); + + // Обновляем сводную информацию + updateSlotsSummary(); +} + +function processAndDisplayCoverageSchemeModes(modes, request) { + currentSlotsData = normalizeCoverageSchemeModes(modes); + currentRequestId = null; + setSlotsContext({ + type: 'coverage-scheme', + key: request.requestId, + dataSourceName: getCoverageSchemeDataSourceName(request.requestId) + }); + + document.getElementById('slotsTitle').textContent = + `Схема покрытия заявки "${request.name}" (${currentSlotsData.length} режимов)`; + document.getElementById('slotsContainer').style.display = 'block'; + + updateSlotsTableHeaders(); + renderSlotsTable(); + updateSlotsSummary(); +} + +function updateSlotsTableHeaders() { + const primaryHeader = document.getElementById('slotsPrimaryHeader'); + const stateHeader = document.getElementById('slotsStateHeader'); + const isCoverageScheme = currentSlotsContext && currentSlotsContext.type === 'coverage-scheme'; + + if (primaryHeader) { + primaryHeader.childNodes[0].textContent = isCoverageScheme ? '№ режима' : '№ слота'; + } + if (stateHeader) { + stateHeader.childNodes[0].textContent = isCoverageScheme ? '' : 'Статус'; + } +} + +// Рендеринг таблицы слотов +function renderSlotsTable() { + const tbody = document.getElementById('slotsTableBody'); + if (!tbody) return; + + // Очищаем таблицу + tbody.innerHTML = ''; + + if (currentSlotsData.length === 0) { + tbody.innerHTML = ` + + + Нет данных для отображения + + + `; + return; + } + + + + // Сортируем данные + const sortedSlots = [...currentSlotsData].sort((a, b) => { + const column = slotsSortConfig.column; + const direction = slotsSortConfig.direction === 'asc' ? 1 : -1; + + let aValue, bValue; + + switch(column) { + case 'cycle': + aValue = a.cycle || a.sequence || 0; + bValue = b.cycle || b.sequence || 0; + return (aValue - bValue) * direction; + + case 'satellite': + aValue = a.satelliteId || 0; + bValue = b.satelliteId || 0; + return (aValue - bValue) * direction; + + case 'tn': + aValue = new Date(a.tn).getTime(); + bValue = new Date(b.tn).getTime(); + return (aValue - bValue) * direction; + + case 'tk': + aValue = new Date(a.tk).getTime(); + bValue = new Date(b.tk).getTime(); + return (aValue - bValue) * direction; + + case 'duration': + aValue = calculateDuration(a.tn, a.tk); + bValue = calculateDuration(b.tn, b.tk); + return (aValue - bValue) * direction; + + case 'roll': + aValue = a.roll || 0; + bValue = b.roll || 0; + return (aValue - bValue) * direction; + + case 'revolution': + aValue = a.revolution || 0; + bValue = b.revolution || 0; + return (aValue - bValue) * direction; + + case 'sign': + aValue = a.revolutionSign || ''; + bValue = b.revolutionSign || ''; + return aValue.localeCompare(bValue) * direction; + + case 'slotNumber': + aValue = a.slotNumber || 0; + bValue = b.slotNumber || 0; + return (aValue - bValue) * direction; + + case 'state': + aValue = getStateSortOrder(a.state); + bValue = getStateSortOrder(b.state); + return (aValue - bValue) * direction; + + default: + return 0; + } + }); + + + // Рендерим строки + sortedSlots.forEach((slot, index) => { + const row = document.createElement('tr'); + row.innerHTML = createSlotRowHTML(slot, slot.index); + tbody.appendChild(row); + }); + + + + // Обновляем иконки сортировки + updateSlotsSortIcons(); +} + +// Создание HTML для строки слота +function createSlotRowHTML(slot, index) { + const tn = formatDateTime(slot.tn); + const duration = calculateDuration(slot.tn, slot.tk); + const durationFormatted = formatDuration(duration); + const isCoverageScheme = currentSlotsContext && currentSlotsContext.type === 'coverage-scheme'; + const primaryValue = isCoverageScheme + ? (slot.sequence || '-') + : (slot.slotNumber || '-'); + const stateContent = slot.state === undefined || slot.state === null || slot.state === '' + ? '' + : (() => { + const stateFormatted = formatState(slot.state); + const stateClass = getStateClass(slot.state); + return ` + + ${stateFormatted.text} + + `; + })(); + + return ` + ${primaryValue} + SAT-${slot.satelliteId || '-'} + ${tn} + ${stateContent} + ${durationFormatted} + + + + + + + `; +} + +// Форматирование даты и времени +function formatDateTime(dateTimeString) { + if (!dateTimeString) return '-'; + + try { + const date = new Date(dateTimeString); + return date.toLocaleString('ru-RU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (e) { + console.error('Ошибка форматирования даты:', e); + return dateTimeString; + } +} + +// Расчет длительности в минутах +function calculateDuration(tn, tk) { + if (!tn || !tk) return 0; + + const startDate = new Date(tn); + const endDate = new Date(tk); + + return (endDate - startDate) / (1000 * 60); // в минутах +} + +// Форматирование длительности +function formatDuration(minutes) { + if (minutes < 1) { + const seconds = Math.round(minutes * 60); + return `${seconds} сек`; + } + + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + + if (hours > 0) { + return `${hours}ч ${mins}м`; + } + + return `${mins} мин`; +} + +// Форматирование крена +function formatRoll(roll) { + if (roll === undefined || roll === null) return '-'; + return roll.toFixed(2) + '°'; +} + +// Форматирование направления витка +function formatRevolutionSign(sign) { + const signs = { + 'ASC': { text: '↑', class: 'bg-info', title: 'Восходящий виток' }, + 'DESC': { text: '↓', class: 'bg-warning', title: 'Нисходящий виток' }, + 'NONE': { text: '-', class: 'bg-secondary', title: 'Не определено' } + }; + + return signs[sign] || signs['NONE']; +} + +// Форматирование статуса +function formatState(state) { + const stateKey = typeof state === 'string' ? state.trim().toUpperCase() : state; + + const states = { + BOOKED: { text: 'Бронь', class: 'bg-success text-white' }, + AVAILABLE: { text: 'Свободен', class: 'bg-white text-dark border border-secondary' }, + BUSY: { text: 'Блок', class: 'bg-secondary text-white' }, + 1: { text: 'Бронь', class: 'bg-success text-white' }, + 0: { text: 'Свободен', class: 'bg-white text-dark border border-secondary' }, + 2: { text: 'Блок', class: 'bg-secondary text-white' }, + 3: { text: 'Блок', class: 'bg-secondary text-white' }, + 4: { text: 'Блок', class: 'bg-secondary text-white' } + }; + + return states[stateKey] || { text: String(state ?? '-'), class: 'bg-secondary text-white' }; +} + +function getStateClass(state) { + const stateInfo = formatState(state); + return stateInfo.class; +} + +function getStateSortOrder(state) { + const stateKey = typeof state === 'string' ? state.trim().toUpperCase() : state; + + switch (stateKey) { + case 'AVAILABLE': + case 0: + return 0; + case 'BOOKED': + case 1: + return 1; + case 'BUSY': + case 2: + case 3: + case 4: + return 2; + default: + return 99; + } +} + +// Обновление сводной информации +function updateSlotsSummary() { + if (currentSlotsData.length === 0) { + document.getElementById('totalSlotsCount').textContent = '0'; + document.getElementById('totalDuration').textContent = '0'; + document.getElementById('satellitesCount').textContent = '0'; + document.getElementById('coveragePeriod').textContent = '-'; + return; + } + + // Общее количество + const totalSlots = currentSlotsData.length; + + // Общая длительность + const totalDuration = currentSlotsData.reduce((sum, slot) => { + return sum + calculateDuration(slot.tn, slot.tk); + }, 0); + + // Количество уникальных спутников + const uniqueSatellites = new Set( + currentSlotsData.map(slot => slot.satelliteId) + ).size; + + // Период покрытия + const startTimes = currentSlotsData.map(slot => new Date(slot.tn)); + const endTimes = currentSlotsData.map(slot => new Date(slot.tk)); + const earliest = new Date(Math.min(...startTimes)); + const latest = new Date(Math.max(...endTimes)); + + const period = `${earliest.toLocaleDateString('ru-RU')} - ${latest.toLocaleDateString('ru-RU')}`; + + // Обновляем элементы + document.getElementById('totalSlotsCount').textContent = totalSlots; + document.getElementById('totalDuration').textContent = totalDuration.toFixed(1); + document.getElementById('satellitesCount').textContent = uniqueSatellites; + document.getElementById('coveragePeriod').textContent = period; +} + +// Сортировка таблицы слотов +function sortSlotsTable(column) { + // Переключаем направление сортировки + if (slotsSortConfig.column === column) { + slotsSortConfig.direction = slotsSortConfig.direction === 'asc' ? 'desc' : 'asc'; + } else { + slotsSortConfig.column = column; + slotsSortConfig.direction = 'asc'; + } + + // Перерисовываем таблицу + renderSlotsTable(); +} + +// Обновление иконок сортировки слотов +function updateSlotsSortIcons() { + // Сбрасываем все иконки + document.querySelectorAll('.sortable-slot .sort-icon i').forEach(icon => { + icon.className = 'bi bi-arrow-down-up'; + }); + + // Устанавливаем активную иконку + const activeIcon = document.getElementById(`sort-icon-${slotsSortConfig.column}`); + if (activeIcon) { + const icon = activeIcon.querySelector('i'); + if (icon) { + icon.className = slotsSortConfig.direction === 'asc' + ? 'bi bi-sort-down' + : 'bi bi-sort-up'; + } + } +} + +// Очистка таблицы слотов +function clearSlotsTable() { + currentSlotsData = []; + currentRequestId = null; + setSlotsContext(null); + updateSlotsTableHeaders(); + + const tbody = document.getElementById('slotsTableBody'); + if (tbody) { + tbody.innerHTML = ` + + + Выберите заявку и включите покрытие для отображения интервалов + + + `; + } + + // Обновляем сводку + document.getElementById('totalSlotsCount').textContent = '0'; + document.getElementById('totalDuration').textContent = '0'; + document.getElementById('satellitesCount').textContent = '0'; + document.getElementById('coveragePeriod').textContent = '-'; + const toggleAllCheckbox = document.getElementById('toggleAllSlotsVisibility'); + if (toggleAllCheckbox) { + toggleAllCheckbox.checked = true; + } + + // Скрываем контейнер + document.getElementById('slotsContainer').style.display = 'none'; +} + +// Показ сообщения об отсутствии данных +function showEmptySlotsMessage(message) { + const tbody = document.getElementById('slotsTableBody'); + if (tbody) { + tbody.innerHTML = ` + + + ${message || 'Нет данных для отображения'} + + + `; + } + document.getElementById('slotsContainer').style.display = 'block'; + updateSlotsActionButtons(); +} + +// Экспорт в CSV +function exportSlotsToCSV() { + if (currentSlotsData.length === 0) { + alert('Нет данных для экспорта'); + return; + } + + // Заголовки CSV + const headers = [ + 'Цикл', 'Спутник', 'Начало', 'Окончание', 'Длительность (мин)', + 'Крен (°)', 'Виток', 'Направление', '№ слота', 'Статус' + ]; + + // Данные + const rows = currentSlotsData.map(slot => [ + slot.cycle || slot.sequence || '', + slot.satelliteId || '', + formatDateTime(slot.tn), + formatDateTime(slot.tk), + calculateDuration(slot.tn, slot.tk).toFixed(2), + slot.roll === undefined || slot.roll === null ? '' : Number(slot.roll).toFixed(2), + slot.revolution || '', + slot.revolutionSign || '', + slot.slotNumber || '', + slot.state || '' + ]); + + // Формируем CSV + let csvContent = 'data:text/csv;charset=utf-8,\uFEFF'; + csvContent += headers.join(';') + '\n'; + csvContent += rows.map(row => row.join(';')).join('\n'); + + // Создаем ссылку для скачивания + const encodedUri = encodeURI(csvContent); + const link = document.createElement('a'); + link.setAttribute('href', encodedUri); + const contextKey = currentSlotsContext ? currentSlotsContext.key : 'export'; + link.setAttribute('download', `slots_${contextKey}_${new Date().toISOString().slice(0,10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +function collectVisibleSlotsForBooking() { + const visibleButtons = document.querySelectorAll( + '#slotsTableBody button[title="Скрыть на карте"]' + ); + const uniqueSlots = new Map(); + + visibleButtons.forEach(button => { + const dataSlotIndex = button.getAttribute('data-slot-index'); + let slotIndex = null; + + if (dataSlotIndex !== null && dataSlotIndex !== '') { + const parsed = Number(dataSlotIndex); + if (Number.isFinite(parsed)) { + slotIndex = parsed; + } + } + + if (!Number.isFinite(slotIndex)) { + const onClickValue = button.getAttribute('onclick') || ''; + const matches = onClickValue.match(/viewSlot\((\d+)/); + if (matches) { + const parsed = Number(matches[1]); + if (Number.isFinite(parsed)) { + slotIndex = parsed; + } + } + } + + if (!Number.isFinite(slotIndex) || !currentSlotsData[slotIndex]) { + return; + } + + const slot = currentSlotsData[slotIndex]; + const satelliteId = Number(slot.satelliteId); + const cycle = Number(slot.cycle); + const rawSlotNum = slot.slotNumber ?? slot.slotNum; + const slotNum = Number(rawSlotNum); + + if (!Number.isFinite(satelliteId) || !Number.isFinite(slotNum) || !Number.isFinite(cycle)) { + return; + } + + const slotKey = `${satelliteId}:${slotNum}:${cycle}`; + if (!uniqueSlots.has(slotKey)) { + uniqueSlots.set(slotKey, { + satelliteId: satelliteId, + slotNum: slotNum, + cycle: cycle + }); + } + }); + + return Array.from(uniqueSlots.values()); +} + +function bookVisibleSlots(button) { + if (!currentRequestId) { + alert('Нет выбранной заявки для бронирования'); + return; + } + + const slots = collectVisibleSlotsForBooking(); + if (slots.length === 0) { + alert('Нет слотов с активной кнопкой "Скрыть на карте"'); + return; + } + + const requestBody = { + requestId: String(currentRequestId), + slots: slots + }; + + if (button) { + button.disabled = true; + } + + fetch('/api/slots/booking/request', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }) + .then(async response => { + if (!response.ok) { + const rawError = await response.text(); + let errorMessage = rawError; + try { + const parsedError = JSON.parse(rawError); + errorMessage = parsedError.error || parsedError.message || rawError; + } catch (_) {} + throw new Error(`HTTP error! status: ${response.status} message: ${errorMessage}`); + } + return response.json(); + }) + .then(json => { + const bookedCount = Array.isArray(json) ? json.length : 0; + alert(`Забронировано слотов: ${bookedCount}`); + }) + .catch(error => { + console.error('Ошибка при бронировании слотов:', error); + alert('Ошибка при бронировании слотов: ' + error.message); + }) + .finally(() => { + if (button) { + button.disabled = false; + } + }); +} + +function cancelRequestBooking(button) { + if (!currentRequestId) { + alert('Нет выбранной заявки для отмены бронирования'); + return; + } + + if (button) { + button.disabled = true; + } + + fetch(`/api/slots/booking/request?requestId=${encodeURIComponent(String(currentRequestId))}`, { + method: 'DELETE', + headers: { + 'Accept': 'application/json' + } + }) + .then(async response => { + if (!response.ok) { + const rawError = await response.text(); + let errorMessage = rawError; + try { + const parsedError = JSON.parse(rawError); + errorMessage = parsedError.error || parsedError.message || rawError; + } catch (_) {} + throw new Error(`HTTP error! status: ${response.status} message: ${errorMessage}`); + } + return response.json(); + }) + .then(result => { + const cancelledCount = Number(result) || 0; + alert(`Отменено бронирований: ${cancelledCount}`); + }) + .catch(error => { + console.error('Ошибка при отмене бронирования:', error); + alert('Ошибка при отмене бронирования: ' + error.message); + }) + .finally(() => { + if (button) { + button.disabled = false; + } + }); +} + + +let prevPoly = null +let materialColor = Cesium.Color.YELLOW +let outlineColor = Cesium.Color.RED + + +// Полет к слоту на карте +function flyToSlot(slotIndex) { + if (!currentSlotsData[slotIndex]) return; + + const slot = currentSlotsData[slotIndex]; + const dataSource = getCurrentSlotsDataSource(); + + if (!dataSource) { + console.warn('DataSource не найден для слота'); + return; + } + + const entityName = slot.entityId || buildSlotEntityId(slot, slotIndex); + const entity = dataSource.entities.getById(entityName); + + if (entity) { + + if (prevPoly != null){ + prevPoly.polygon.material = materialColor; + prevPoly.polygon.outlineColor = outlineColor; + } + + prevPoly = entity + materialColor = entity.polygon.material + outlineColor = entity.polygon.outlineColor + + entity.polygon.material = Cesium.Color.YELLOW.withAlpha(0.4); + entity.polygon.outline = true; + entity.polygon.outlineColor = Cesium.Color.BLUE; + entity.polygon.outlineWidth = 3.0; + + viewer.selectedEntity = entity; + + viewer.flyTo(entity, { + duration: 2 + }); + } else { + console.warn('Entity не найден для слота:', entityName); + } +} + + + + +function viewSlot(slotIndex, button) { + if (!currentSlotsData[slotIndex]) return; + + const slot = currentSlotsData[slotIndex]; + const dataSource = getCurrentSlotsDataSource(); + + if (!dataSource) { + console.warn('DataSource не найден для слота'); + return; + } + + const entityName = slot.entityId || buildSlotEntityId(slot, slotIndex); + const entity = dataSource.entities.getById(entityName); + + if (entity) { + //console.log(entityName) + let visibility = entity.polygon.show + //console.log(visibility) + if (visibility == false){ + entity.polygon.show = true + updateVisibilityButton(button, true); + } + else { + entity.polygon.show = false + updateVisibilityButton(button, false); + } + + + } else { + console.warn('Entity не найден для слота:', entityName); + } +} + +// Обновление состояния кнопки +function updateVisibilityButton(button, isVisible) { + const icon = button.querySelector('i'); + if (!icon) return; + + if (isVisible) { + // Полигон видим - показываем иконку "скрыть" + icon.className = 'bi bi-eye'; + button.title = 'Скрыть на карте'; + button.classList.remove('btn-outline-secondary'); + button.classList.add('btn-outline-primary'); + } else { + // Полигон скрыт - показываем иконку "показать" + icon.className = 'bi bi-eye-slash'; + button.title = 'Показать на карте'; + button.classList.remove('btn-outline-primary'); + button.classList.add('btn-outline-secondary'); + } +} 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 new file mode 100644 index 0000000..a78a545 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/rva_scripts.js @@ -0,0 +1,247 @@ +(function () { + const pageData = window.rvaPageData; + if (!pageData) { + return; + } + + const apiBase = '/api/rva'; + const stationsByNumber = new Map((pageData.stations || []).map(station => [station.number, station])); + + const resultState = { + mode: 'common', + rows: [] + }; + + function showAlert(message, type = 'danger') { + const container = document.getElementById('rva-alert'); + if (!message) { + container.innerHTML = ''; + return; + } + container.innerHTML = ``; + } + + function formatDateTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString('ru-RU'); + } + + function selectedMode() { + return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common'; + } + + function selectedStations() { + return Array.from(document.querySelectorAll('.rva-station-checkbox:checked')) + .map(input => Number(input.dataset.number)) + .map(number => stationsByNumber.get(number)) + .filter(Boolean); + } + + async function requestJson(url, payload) { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const body = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (body && typeof body === 'object') { + throw new Error(body.error || Object.values(body).join('; ')); + } + throw new Error(body || `HTTP ${response.status}`); + } + + return body; + } + + function renderResults() { + const head = document.getElementById('rva-results-head'); + const body = document.getElementById('rva-results-body'); + const meta = document.getElementById('rva-result-meta'); + const downloadButton = document.getElementById('rva-download'); + + if (!resultState.rows.length) { + head.innerHTML = ''; + body.innerHTML = 'Нет результатов'; + meta.textContent = 'Выберите параметры и запустите расчет.'; + downloadButton.disabled = true; + return; + } + + if (resultState.mode === 'common') { + head.innerHTML = ` + + Станция + Виток + Начало + Максимум + Конец + Длительность, с + + `; + body.innerHTML = resultState.rows.map(row => ` + + ${row.stationId} + ${row.revolution} + ${formatDateTime(row.onStart.time)} + ${formatDateTime(row.onMaximum.time)} + ${formatDateTime(row.onStop.time)} + ${row.duration} + + `).join(''); + } else if (resultState.mode === 'merge') { + head.innerHTML = ` + + Начало + Конец + Длительность, с + + `; + body.innerHTML = resultState.rows.map(row => ` + + ${formatDateTime(row.begin)} + ${formatDateTime(row.end)} + ${row.durationSec} + + `).join(''); + } else { + head.innerHTML = ` + + Виток + Суммарная длительность, с + + `; + body.innerHTML = resultState.rows.map(row => ` + + ${row.revolution} + ${row.durationSec} + + `).join(''); + } + + meta.textContent = `Получено строк: ${resultState.rows.length}`; + downloadButton.disabled = false; + } + + function csvEscape(value) { + const text = String(value ?? ''); + if (text.includes(';') || text.includes('"') || text.includes('\n')) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; + } + + function downloadCsv() { + if (!resultState.rows.length) { + return; + } + + const lines = resultState.mode === 'common' + ? [ + ['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'], + ...resultState.rows.map(row => [ + row.stationId, + row.revolution, + row.onStart.time, + row.onMaximum.time, + row.onStop.time, + row.duration + ]) + ] + : resultState.mode === 'merge' + ? [ + ['begin', 'end', 'durationSec'], + ...resultState.rows.map(row => [ + row.begin, + row.end, + row.durationSec + ]) + ] + : [ + ['revolution', 'durationSec'], + ...resultState.rows.map(row => [ + row.revolution, + row.durationSec + ]) + ]; + + const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `rva-${resultState.mode}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + document.getElementById('rva-select-all').addEventListener('click', () => { + document.querySelectorAll('.rva-station-checkbox').forEach(input => { + input.checked = true; + }); + }); + + document.getElementById('rva-reset').addEventListener('click', () => { + setTimeout(() => { + showAlert(''); + resultState.rows = []; + resultState.mode = selectedMode(); + renderResults(); + }, 0); + }); + + document.getElementById('rva-download').addEventListener('click', downloadCsv); + + document.getElementById('rva-form').addEventListener('submit', async event => { + event.preventDefault(); + showAlert(''); + + const satelliteId = Number(document.getElementById('rva-satellite').value); + const duration = Number(document.getElementById('rva-duration').value); + const mode = selectedMode(); + const stations = selectedStations(); + + if (!satelliteId) { + showAlert('Нужно выбрать спутник'); + return; + } + if (!duration || duration < 1) { + showAlert('Горизонт расчета должен быть положительным'); + return; + } + if (!stations.length) { + showAlert('Нужно выбрать хотя бы одну станцию'); + return; + } + + const submitButton = event.submitter; + submitButton.disabled = true; + submitButton.textContent = 'Расчет...'; + + try { + const rows = await requestJson(`${apiBase}/${mode}/${satelliteId}/${duration}`, stations); + resultState.mode = mode; + resultState.rows = rows; + renderResults(); + showAlert('Расчет выполнен', 'success'); + } catch (error) { + showAlert(error.message || 'Не удалось выполнить расчет'); + } finally { + submitButton.disabled = false; + submitButton.textContent = 'Рассчитать'; + } + }); + + renderResults(); +}()); 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 new file mode 100644 index 0000000..afa23eb --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js @@ -0,0 +1,322 @@ +(function () { + const pageRoot = document.getElementById('satellite-pdcm-page'); + if (!pageRoot) { + return; + } + + const apiBase = '/api/catalog/satellites/pdcm'; + const state = { + satellites: [], + selectedSatelliteId: null, + details: null + }; + + function showAlert(message, type = 'danger') { + const container = document.getElementById('satellite-pdcm-alert'); + if (!message) { + container.innerHTML = ''; + return; + } + container.innerHTML = ``; + } + + async function requestJson(url, options = {}) { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json' + }, + ...options + }); + + if (response.status === 204) { + return null; + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const payload = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (payload && typeof payload === 'object') { + throw new Error(payload.error || Object.values(payload).join('; ')); + } + throw new Error(payload || `HTTP ${response.status}`); + } + + return payload; + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function formatDateTimeLabel(value) { + return value ? value.replace('T', ' ') : '-'; + } + + function formatNumber(value) { + return Number(value ?? 0).toFixed(3); + } + + function formatNullableNumber(value) { + return value == null ? '-' : formatNumber(value); + } + + function renderExportState(details) { + const exportButton = document.getElementById('satellite-pdcm-export'); + exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length; + } + + function renderList() { + const tableBody = document.getElementById('satellite-pdcm-list'); + tableBody.innerHTML = ''; + + if (!state.satellites.length) { + tableBody.innerHTML = 'Спутники не найдены'; + return; + } + + state.satellites.forEach(satellite => { + const tr = document.createElement('tr'); + if (satellite.satelliteId === state.selectedSatelliteId) { + tr.classList.add('table-active'); + } + const interval = satellite.availableInterval + ? ` + ${escapeHtml(formatDateTimeLabel(satellite.availableInterval.timeStart))} + ${escapeHtml(formatDateTimeLabel(satellite.availableInterval.timeStop))} + ` + : 'Нет ПДЦМ'; + tr.innerHTML = ` + +
+ + ${escapeHtml(satellite.name)} +
+ + ${interval} + `; + tr.addEventListener('click', () => selectSatellite(satellite.satelliteId)); + tableBody.appendChild(tr); + }); + } + + function renderSummary(details) { + const summary = document.getElementById('satellite-pdcm-summary'); + if (!details) { + summary.innerHTML = '
Выберите спутник слева.
'; + renderExportState(null); + return; + } + + const interval = details.availableInterval + ? `${formatDateTimeLabel(details.availableInterval.timeStart)} - ${formatDateTimeLabel(details.availableInterval.timeStop)}` + : 'Нет доступного интервала'; + const status = details.activeInterval ? 'Активен сейчас' : 'Неактивен сейчас'; + + summary.innerHTML = ` +
+
+
Спутник
+
${escapeHtml(details.name)}
+
${escapeHtml(details.code || String(details.satelliteId))} · ID ${details.satelliteId}
+
+
+
Статус
+
${escapeHtml(status)}
+
+
+
Интервал ПДЦМ
+
${escapeHtml(interval)}
+
+
+
Записей asc-node
+
${details.ascNodes.length}
+
+
+ `; + renderExportState(details); + } + + function renderTable(details) { + const empty = document.getElementById('satellite-pdcm-table-empty'); + const wrap = document.getElementById('satellite-pdcm-table-wrap'); + + if (!details || !details.availableInterval || !details.ascNodes.length) { + empty.classList.remove('d-none'); + wrap.classList.add('d-none'); + wrap.innerHTML = ''; + renderExportState(details); + return; + } + + const rows = details.ascNodes.map(item => ` + + ${escapeHtml(formatDateTimeLabel(item.time))} + ${item.revolution} + ${formatNumber(item.long)} + ${formatNumber(item.height)} + ${escapeHtml(formatDateTimeLabel(item.localMeanTime))} + ${formatNullableNumber(item.keps?.ael)} + ${formatNullableNumber(item.keps?.e)} + ${formatNullableNumber(item.keps?.inkl)} + ${formatNullableNumber(item.keps?.omega)} + ${formatNullableNumber(item.keps?.w)} + ${formatNumber(item.x)} + ${formatNumber(item.y)} + ${formatNumber(item.z)} + ${formatNumber(item.vx)} + ${formatNumber(item.vy)} + ${formatNumber(item.vz)} + + `).join(''); + + wrap.innerHTML = ` + + + + + + + + + + + + + + + + + + + + + + ${rows} +
ВремяВитокДолготаВысотаМестное ср. времяaeiΩωXYZVxVyVz
+ `; + empty.classList.add('d-none'); + wrap.classList.remove('d-none'); + renderExportState(details); + } + + function buildCsvValue(value) { + const text = String(value ?? ''); + return `"${text.replaceAll('"', '""')}"`; + } + + function exportCurrentCsv() { + if (!state.details || !state.details.ascNodes.length) { + return; + } + + const rows = [ + [ + 'Время', + 'Виток', + 'Долгота', + 'Высота', + 'Местное ср. время', + 'a', + 'e', + 'i', + 'Omega', + 'omega', + 'X', + 'Y', + 'Z', + 'Vx', + 'Vy', + 'Vz' + ], + ...state.details.ascNodes.map(item => [ + formatDateTimeLabel(item.time), + item.revolution, + formatNumber(item.long), + formatNumber(item.height), + formatDateTimeLabel(item.localMeanTime), + item.keps?.ael ?? '', + item.keps?.e ?? '', + item.keps?.inkl ?? '', + item.keps?.omega ?? '', + item.keps?.w ?? '', + formatNumber(item.x), + formatNumber(item.y), + formatNumber(item.z), + formatNumber(item.vx), + formatNumber(item.vy), + formatNumber(item.vz) + ]) + ]; + + const csv = rows + .map(row => row.map(buildCsvValue).join(';')) + .join('\n'); + + const fileName = `asc-node-${(state.details.code || state.details.satelliteId)}.csv`; + const blob = new Blob([`\uFEFF${csv}`], {type: 'text/csv;charset=utf-8;'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + async function selectSatellite(satelliteId) { + state.selectedSatelliteId = satelliteId; + renderList(); + showAlert(''); + + try { + state.details = await requestJson(`${apiBase}/${satelliteId}`); + renderSummary(state.details); + renderTable(state.details); + renderList(); + } catch (error) { + state.details = null; + renderSummary(null); + renderTable(null); + showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника'); + } + } + + async function loadSatellites() { + showAlert(''); + try { + state.satellites = await requestJson(apiBase); + renderList(); + const nextSatelliteId = state.selectedSatelliteId + ?? state.satellites.find(item => item.activeInterval)?.satelliteId + ?? state.satellites[0]?.satelliteId; + if (nextSatelliteId) { + await selectSatellite(nextSatelliteId); + } else { + state.details = null; + renderSummary(null); + renderTable(null); + } + } catch (error) { + state.satellites = []; + state.details = null; + renderList(); + renderSummary(null); + renderTable(null); + showAlert(error.message || 'Не удалось загрузить список спутников'); + } + } + + document.getElementById('satellite-pdcm-refresh').addEventListener('click', () => { + loadSatellites(); + }); + document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv); + + loadSatellites(); +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/stations_scripts.js b/services/pcp-ui-service/src/main/resources/static/stations_scripts.js new file mode 100644 index 0000000..6d93a29 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/stations_scripts.js @@ -0,0 +1,185 @@ +(function () { + const pageRoot = document.getElementById('stations-page'); + if (!pageRoot) { + return; + } + + const state = { + stations: [], + selectedStationId: null + }; + + const tableBody = document.getElementById('stations-table-body'); + const searchInput = document.getElementById('stations-search'); + + function showAlert(message, type = 'danger') { + const container = document.getElementById('stations-alert'); + if (!message) { + container.innerHTML = ''; + return; + } + container.innerHTML = ``; + } + + async function requestJson(url, options = {}) { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json' + }, + ...options + }); + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const payload = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + if (payload && typeof payload === 'object') { + if (payload.error) { + throw new Error(payload.error); + } + if (payload.message) { + throw new Error(payload.message); + } + } + throw new Error(payload || `HTTP ${response.status}`); + } + + return payload; + } + + function formPayload() { + const stationId = document.getElementById('station-id').value.trim(); + return { + id: stationId || null, + number: Number(document.getElementById('station-number').value), + name: document.getElementById('station-name').value.trim(), + position: { + lat: Number(document.getElementById('station-lat').value), + long: Number(document.getElementById('station-long').value), + height: Number(document.getElementById('station-height').value) + }, + elevationMin: Number(document.getElementById('station-elevation-min').value), + elevationMax: Number(document.getElementById('station-elevation-max').value) + }; + } + + function renderStations() { + const query = searchInput.value.trim().toLowerCase(); + const items = state.stations.filter(station => + !query || + String(station.number).includes(query) || + station.name.toLowerCase().includes(query) + ); + + tableBody.innerHTML = ''; + if (!items.length) { + tableBody.innerHTML = 'Станции не найдены'; + return; + } + + items.forEach(station => { + const row = document.createElement('tr'); + if (station.id === state.selectedStationId) { + row.classList.add('table-active'); + } + row.innerHTML = ` + ${station.number} + ${station.name} + ${station.elevationMin ?? ''} + ${station.elevationMax ?? ''} + `; + row.addEventListener('click', () => selectStation(station)); + tableBody.appendChild(row); + }); + } + + function resetForm() { + document.getElementById('station-form').reset(); + document.getElementById('station-id').value = ''; + document.getElementById('station-form-title').textContent = 'Новая станция'; + state.selectedStationId = null; + renderStations(); + showAlert(''); + } + + function selectStation(station) { + state.selectedStationId = station.id ?? null; + document.getElementById('station-id').value = station.id ?? ''; + document.getElementById('station-number').value = station.number; + document.getElementById('station-name').value = station.name; + document.getElementById('station-lat').value = station.position?.lat ?? ''; + document.getElementById('station-long').value = station.position?.long ?? ''; + document.getElementById('station-height').value = station.position?.height ?? ''; + document.getElementById('station-elevation-min').value = station.elevationMin; + document.getElementById('station-elevation-max').value = station.elevationMax; + document.getElementById('station-form-title').textContent = `Станция ${station.name}`; + renderStations(); + showAlert(''); + } + + async function loadStations() { + state.stations = await requestJson('/api/stations'); + renderStations(); + + if (state.selectedStationId) { + const selected = state.stations.find(station => station.id === state.selectedStationId); + if (selected) { + selectStation(selected); + return; + } + } + if (!document.getElementById('station-id').value) { + resetForm(); + } + } + + document.getElementById('stations-refresh').addEventListener('click', async () => { + try { + await loadStations(); + } catch (error) { + showAlert(error.message); + } + }); + + document.getElementById('stations-new').addEventListener('click', resetForm); + document.getElementById('stations-reset').addEventListener('click', () => { + window.setTimeout(resetForm, 0); + }); + searchInput.addEventListener('input', renderStations); + + document.getElementById('station-form').addEventListener('submit', async event => { + event.preventDefault(); + try { + const saved = await requestJson('/api/stations', { + method: 'POST', + body: JSON.stringify(formPayload()) + }); + state.selectedStationId = saved.id ?? null; + await loadStations(); + showAlert('Станция сохранена', 'success'); + } catch (error) { + showAlert(error.message); + } + }); + + document.getElementById('stations-delete').addEventListener('click', async () => { + const stationId = document.getElementById('station-id').value.trim(); + if (!stationId) { + showAlert('Сначала выберите станцию для удаления'); + return; + } + if (!window.confirm(`Удалить станцию ${stationId}?`)) { + return; + } + try { + await requestJson(`/api/stations/${stationId}`, { method: 'DELETE' }); + resetForm(); + await loadStations(); + showAlert('Станция удалена', 'success'); + } catch (error) { + showAlert(error.message); + } + }); + + loadStations().catch(error => showAlert(error.message)); +})(); diff --git a/services/pcp-ui-service/src/main/resources/templates/complex-plan.html b/services/pcp-ui-service/src/main/resources/templates/complex-plan.html new file mode 100644 index 0000000..afb1e3f --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/complex-plan.html @@ -0,0 +1,156 @@ + + + + Ком.План + + + + +
+
+
+

Ком.План

+
История расчетов комплексного плана и параметры выбранного запуска.
+
+ +
+ +
+ +
+
+
+
+
+
Расчеты
+
Загрузка...
+
+
+
+ + + + + + + + + + + + + + + +
IDСтатусИнтервалКА
Загрузка...
+
+
+
+ +
+
+
+
Запуск расчета
+
Создание новой версии комплексного плана.
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
Детали расчета
+
Выберите расчет слева.
+
+ +
+
+
Нет выбранного расчета
+
+
+
+
+
+ + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/current-plans.html b/services/pcp-ui-service/src/main/resources/templates/current-plans.html new file mode 100644 index 0000000..0ac264d --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/current-plans.html @@ -0,0 +1,164 @@ + + + + Текущие планы + + + + + +
+
+
+

Текущие планы

+
Просмотр миссий выбранного спутника и режимов работы внутри миссии.
+
+
+ + +
+
+ +
+ +
+
+
+
+
Спутники
+
Выберите КА для просмотра текущих планов.
+
+
+ + +
+
+ + + + + + + + + +
IDСпутникТип
+
+
+
+ +
+
+
+
+
+
Миссии выбранного спутника
+
Выберите спутник слева.
+
+
+ + 0 / 0 + +
+
+
+
Выберите спутник для загрузки миссий.
+
+ + + + + + + + + + + + +
МиссияНачалоКонецСтанцияСтатусДействия
+
+
+
+ +
+
+
+
Режимы работы внутри миссии
+
Выберите миссию в верхней таблице.
+
+ +
+
+
Режимы появятся после выбора миссии.
+
+ + + + + + + + + + + + +
ТипНачалоВитокДлительность, сПараметрыСтатус / источник
+
+
+
+
+
+
+
+ + + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/dynamic-plan.html b/services/pcp-ui-service/src/main/resources/templates/dynamic-plan.html new file mode 100644 index 0000000..41bd002 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/dynamic-plan.html @@ -0,0 +1,171 @@ + + + + Dynamic Plan + + + + +
+
+
+

Dynamic Plan

+
Запуск расчета модели комплексного плана по одной заявке.
+
+
+ +
+ +
+
+
+
+
+
Параметры расчета
+
UUID заявки, выбранные КА и расчетный интервал.
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+
+
+ +
+ + + + + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+
История запусков
+
Последние расчеты.
+
+ +
+
+
Нет данных
+
+
+
+ +
+
+
+
+
Запуск и результат
+
Расчет еще не запускался.
+
+
+
+
Нет данных
+
+
+
+
+
+ + + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/general.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/general.html new file mode 100644 index 0000000..b367d44 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/general.html @@ -0,0 +1,15 @@ + + + + + + + + +
+ + +
+ 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 new file mode 100644 index 0000000..2af2716 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -0,0 +1,78 @@ + + + diff --git a/services/pcp-ui-service/src/main/resources/templates/group-state.html b/services/pcp-ui-service/src/main/resources/templates/group-state.html new file mode 100644 index 0000000..3d8ad1c --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/group-state.html @@ -0,0 +1,90 @@ + + + + Состояние ОГ + + + + + +
+
+
+

Состояние ОГ

+
Состояние орбитальной группировки на выбранный момент времени.
+
+ +
+ +
+ +
+
+
+
+
Доступные группировки
+
+
+ + + + + + + + + +
ОГКАИнтервал
+
+
+
+ +
+
+
+
+
Время
+
+
+
Выберите группировку слева.
+
+
+ + +
+
+ +
+
+
+
+ +
+
+
Таблица
+
+
+
Выберите группировку с доступным интервалом расчета.
+
+
+
+ +
+
+
График
+
+
+
Графики появятся после выбора момента времени.
+
+
+
+
+
+
+
+ +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/groups.html b/services/pcp-ui-service/src/main/resources/templates/groups.html new file mode 100644 index 0000000..8bfdb9a --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/groups.html @@ -0,0 +1,85 @@ + + + + Группировки + + + + +
+
+
+

Группировки

+
Орбитальные группировки и управление их составом.
+
+
+ + +
+
+ +
+ +
+
+
+
+
Список группировок
+
+
+ + + + + + + + + + +
IDНазваниеСоставСпутники
+
+
+
+ +
+
+
+
Новая группировка
+
+
+
+ +
+ + +
+
+
+ + Можно выбрать несколько спутников +
+
+
+
+ + + +
+
+
+
+
+
+
+ + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/index.html b/services/pcp-ui-service/src/main/resources/templates/index.html new file mode 100644 index 0000000..27690c0 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/index.html @@ -0,0 +1,12 @@ + + + + ПЦП + + + + + + \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/resources/templates/layouts/main-layout.html b/services/pcp-ui-service/src/main/resources/templates/layouts/main-layout.html new file mode 100644 index 0000000..cdd91a4 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/layouts/main-layout.html @@ -0,0 +1,22 @@ + + + + + + +
+
+
+
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/services/pcp-ui-service/src/main/resources/templates/map.html b/services/pcp-ui-service/src/main/resources/templates/map.html new file mode 100644 index 0000000..d41cf2c --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/map.html @@ -0,0 +1,2433 @@ + + + + + + + + Карта + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + +
+
+ + + + + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/rva.html b/services/pcp-ui-service/src/main/resources/templates/rva.html new file mode 100644 index 0000000..f5fc046 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/rva.html @@ -0,0 +1,127 @@ + + + + ЗРВ + + + + +
+
+
+

ЗРВ

+
Расчет зон радиовидимости по нескольким станциям с объединением интервалов или без него.
+
+
+ +
+ +
+
+
+
+
Параметры расчета
+
+
+
+
+ + +
+ +
+ + +
+ +
+
Вариант расчета
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+
Нужно выбрать хотя бы одну станцию.
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
Результаты
+
Выберите параметры и запустите расчет.
+
+ +
+
+
+ + + + + + + +
Нет результатов
+
+
+
+
+
+
+ + + +
+ + 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 new file mode 100644 index 0000000..142da35 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html @@ -0,0 +1,73 @@ + + + + ПДЦМ + + + + + +
+
+
+

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

+
Табличное представление asc-node по выбранному спутнику.
+
+ +
+ +
+ +
+
+
+
+
Спутники
+
+
+ + + + + + + + +
СпутникИнтервал
+
+
+
+ +
+
+
+
+
Сводка
+
+
+
+
Выберите спутник слева.
+
+
+
+ +
+
+
Asc-node
+ +
+
+
Нет доступного интервала ПДЦМ для выбранного спутника.
+
+
+
+
+
+
+
+ +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/satellites.html b/services/pcp-ui-service/src/main/resources/templates/satellites.html new file mode 100644 index 0000000..6e791e5 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/satellites.html @@ -0,0 +1,273 @@ + + + + Спутники + + + + +
+
+
+

Спутники

+
Карточки спутников, observation profile и slot profile.
+
+
+ + +
+
+ +
+ +
+
+
+
+
Список спутников
+
+ +
+ + + + + + + + + + + +
IDКодИмяТипСлоты
+
+
+
+ +
+
+
+
+
Новый спутник
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
Observation profile
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
Slot profile
+ +
+
+
+
+
Начальные условия орбиты
+
Начальные условия не заданы.
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Один диапазон на строку: `angleBegin, angleEnd`.
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/stations.html b/services/pcp-ui-service/src/main/resources/templates/stations.html new file mode 100644 index 0000000..8eb2827 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/stations.html @@ -0,0 +1,110 @@ + + + + Станции + + + + +
+
+
+

Станции

+
CRUD-операции над наземными станциями через `stations-service`.
+
+
+ + +
+
+ +
+ +
+
+
+
+
Список станций
+
+
+ +
+
+ + + + + + + + + + +
НомерНазваниеМин.уголМакс.угол
+
+
+
+ +
+
+
+
Новая станция
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+
+
+
+ +
+ + 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 new file mode 100644 index 0000000..e22cffa --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -0,0 +1,524 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +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.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateAvailabilityDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateDetailsDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixCellDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixRowDTO +import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSatelliteDTO +import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO +import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO +import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO +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.SatelliteDTO +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.GroupStateService +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 space.nstart.pcp.slots_service.service.StationService +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.boot.admin.client.enabled=false" + ] +) +class CatalogControllerTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var satelliteCatalogService: SatelliteCatalogService + + @MockitoBean + private lateinit var stationService: StationService + + @MockitoBean + private lateinit var slotService: SlotService + + @MockitoBean + private lateinit var groupStateService: GroupStateService + + @MockitoBean + private lateinit var satellitePdcmService: SatellitePdcmService + + @Test + fun `groups page is rendered`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/groups") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Группировки")) + assertTrue(body.contains("Новая группировка")) + assertTrue(body.contains("dropdown-toggle")) + assertTrue(body.contains(">Управление<")) + assertTrue(body.contains(">Состояние ОГ<")) + } + + @Test + fun `group state page is rendered`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/group-state") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Состояние ОГ")) + assertTrue(body.contains("Доступные группировки")) + assertTrue(body.contains("Показать состояние")) + } + + @Test + fun `satellites page is rendered`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/satellites") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Спутники")) + assertTrue(body.contains("Observation profile")) + assertTrue(body.contains("Slot profile")) + assertTrue(body.contains("Расчет слотов")) + assertTrue(body.contains("slot-calculation-summary")) + assertTrue(body.contains("Начальные условия орбиты")) + assertTrue(body.contains("Сохранить начальные условия")) + assertTrue(body.contains("Слоты")) + } + + @Test + fun `satellite pdcm page is rendered`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/satellites/pdcm") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Параметры движения центра масс")) + assertTrue(body.contains("Asc-node")) + assertTrue(body.contains("Спутники")) + assertTrue(body.contains("Скачать CSV")) + assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css")) + } + + @Test + fun `stations page is rendered`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/stations") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Станции")) + assertTrue(body.contains("Новая станция")) + assertTrue(body.contains("Мин.угол")) + assertTrue(body.contains("Макс.угол")) + assertTrue(body.contains(">Управление<")) + assertTrue(body.contains(">ЗРВ<")) + assertFalse(body.contains("UUID")) + } + + @Test + fun `group create endpoint proxies request`() { + val response = SatelliteGroupDTO( + id = 71L, + name = "KONDOR", + satelliteIds = listOf(56756L, 62138L) + ) + val request = SatelliteGroupCreateDTO( + name = "KONDOR", + satelliteIds = listOf(56756L, 62138L) + ) + doReturn(response).`when`(satelliteCatalogService).createGroup(request) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/catalog/groups") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(SatelliteGroupDTO::class.java) + .block()!! + + assertEquals(71L, actual.id) + assertEquals("KONDOR", actual.name) + verify(satelliteCatalogService).createGroup(request) + } + + @Test + fun `group state endpoint proxies request`() { + val requestedTime = LocalDateTime.of(2026, 4, 24, 12, 15) + val response = GroupStateDetailsDTO( + groupId = 71L, + groupName = "KONDOR", + availableInterval = GroupStateAvailabilityDTO( + timeStart = LocalDateTime.of(2026, 4, 24, 10, 0), + timeStop = LocalDateTime.of(2026, 4, 24, 14, 0) + ), + calculationTime = requestedTime, + satellites = listOf( + GroupStateSatelliteDTO( + satelliteId = 501L, + noradId = 56756L, + code = "SAT-501", + name = "Sat 501", + omegabDeg = 15.0, + uDeg = 45.0 + ) + ), + matrix = listOf( + GroupStateMatrixRowDTO( + satelliteId = 501L, + cells = listOf( + GroupStateMatrixCellDTO( + rowSatelliteId = 501L, + columnSatelliteId = 501L, + deltaOmegabDeg = 0.0, + deltaUDeg = 0.0 + ) + ) + ) + ) + ) + doReturn(response).`when`(groupStateService).groupState(71L, requestedTime) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/group-states/71?time=2026-04-24T12:15:00") + .retrieve() + .bodyToMono(GroupStateDetailsDTO::class.java) + .block()!! + + assertEquals(71L, actual.groupId) + assertEquals(requestedTime, actual.calculationTime) + assertEquals(1, actual.satellites.size) + verify(groupStateService).groupState(71L, requestedTime) + } + + @Test + fun `satellite pdcm endpoint proxies request`() { + val response = SatellitePdcmDetailsDTO( + satelliteId = 501L, + code = "SAT-501", + name = "Sat 501", + activeInterval = true, + availableInterval = SatellitePdcmAvailabilityDTO( + timeStart = LocalDateTime.of(2026, 4, 24, 10, 0), + timeStop = LocalDateTime.of(2026, 4, 24, 12, 0) + ), + ascNodes = emptyList() + ) + doReturn(response).`when`(satellitePdcmService).satelliteDetails(501L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/pdcm/501") + .retrieve() + .bodyToMono(SatellitePdcmDetailsDTO::class.java) + .block()!! + + assertEquals(501L, actual.satelliteId) + assertTrue(actual.activeInterval) + verify(satellitePdcmService).satelliteDetails(501L) + } + + @Test + fun `satellite pdcm list endpoint proxies request`() { + val response = listOf( + SatellitePdcmSummaryDTO( + satelliteId = 501L, + code = "SAT-501", + name = "Sat 501", + activeInterval = true, + availableInterval = SatellitePdcmAvailabilityDTO( + timeStart = LocalDateTime.of(2026, 4, 24, 10, 0), + timeStop = LocalDateTime.of(2026, 4, 24, 12, 0) + ) + ) + ) + doReturn(response).`when`(satellitePdcmService).satelliteSummaries() + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/pdcm") + .retrieve() + .bodyToMono(Array::class.java) + .block()!! + + assertEquals(1, actual.size) + assertEquals(501L, actual[0].satelliteId) + verify(satellitePdcmService).satelliteSummaries() + } + + @Test + fun `satellite create endpoint proxies request`() { + val request = SatelliteCreateDTO( + id = 501L, + noradId = 9501L, + code = "SAT-501", + name = "Sat 501", + typeCode = "TEST", + active = true, + scanTle = true, + visualization = SatelliteVisualizationDTO(red = 11, green = 22, blue = 33) + ) + val response = SatelliteDTO( + id = 501L, + noradId = 9501L, + code = "SAT-501", + name = "Sat 501", + typeCode = "TEST", + active = true, + scanTle = true, + visualization = SatelliteVisualizationDTO(red = 11, green = 22, blue = 33) + ) + doReturn(response).`when`(satelliteCatalogService).createSatellite(request) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/catalog/satellites") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(SatelliteDTO::class.java) + .block()!! + + assertEquals(501L, actual.id) + assertEquals("SAT-501", actual.code) + verify(satelliteCatalogService).createSatellite(request) + } + + @Test + fun `station save endpoint proxies request`() { + val stationId = UUID.fromString("2f1d1d8b-588d-4d4e-a456-80a5c93e09dd") + val request = StationDTO( + id = stationId, + number = 7, + name = "Station 7", + position = PositionDTO( + lat = 55.75, + long = 37.62, + height = 180.0 + ), + elevationMin = 5.0, + elevationMax = 88.0 + ) + doReturn(request).`when`(stationService).saveStation(anyStation()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/stations") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(StationDTO::class.java) + .block()!! + + assertEquals(stationId, actual.id) + assertEquals("Station 7", actual.name) + verify(stationService).saveStation(anyStation()) + } + + @Test + fun `satellite initial conditions get endpoint returns no content when absent`() { + doReturn(null).`when`(slotService).satelliteInitialConditions(501L) + + val status = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/501/initial-conditions") + .exchangeToMono { Mono.just(it.statusCode()) } + .block()!! + + assertEquals(HttpStatus.NO_CONTENT, status) + verify(slotService).satelliteInitialConditions(501L) + } + + @Test + fun `satellite slot calculation summaries endpoint proxies request`() { + val response = listOf( + SlotCalculationSummaryDTO.calculated( + satelliteId = 501L, + slotCount = 10L, + minTime = LocalDateTime.of(2026, 4, 20, 0, 0), + maxTime = LocalDateTime.of(2026, 4, 21, 0, 0), + minRevolution = 100L, + maxRevolution = 112L + ) + ) + doReturn(response).`when`(slotService).slotCalculationSummaries(listOf(501L, 502L)) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/slot-calculation-summaries?satelliteIds=501&satelliteIds=502") + .retrieve() + .bodyToMono(Array::class.java) + .block()!! + + assertEquals(1, actual.size) + assertEquals(501L, actual[0].satelliteId) + assertTrue(actual[0].calculated) + assertEquals(12L, actual[0].revolutionInterval) + verify(slotService).slotCalculationSummaries(listOf(501L, 502L)) + } + + @Test + fun `satellite slot calculation summaries endpoint returns empty summaries when slot service fails`() { + doThrow(RuntimeException("slot service unavailable")) + .`when`(slotService).slotCalculationSummaries(listOf(501L, 502L)) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/slot-calculation-summaries?satelliteIds=501&satelliteIds=502") + .retrieve() + .bodyToMono(Array::class.java) + .block()!! + + assertEquals(2, actual.size) + assertEquals(501L, actual[0].satelliteId) + assertFalse(actual[0].calculated) + assertEquals(502L, actual[1].satelliteId) + assertFalse(actual[1].calculated) + verify(slotService).slotCalculationSummaries(listOf(501L, 502L)) + } + + @Test + fun `satellite slot calculation summary endpoint proxies request`() { + val response = SlotCalculationSummaryDTO.calculated( + satelliteId = 501L, + slotCount = 10L, + minTime = LocalDateTime.of(2026, 4, 20, 0, 0), + maxTime = LocalDateTime.of(2026, 4, 21, 0, 0), + minRevolution = 100L, + maxRevolution = 112L + ) + doReturn(response).`when`(slotService).slotCalculationSummary(501L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/501/slot-calculation-summary") + .retrieve() + .bodyToMono(SlotCalculationSummaryDTO::class.java) + .block()!! + + assertEquals(501L, actual.satelliteId) + assertTrue(actual.calculated) + assertEquals(12L, actual.revolutionInterval) + verify(slotService).slotCalculationSummary(501L) + } + + @Test + fun `satellite slot calculation summary endpoint returns empty summary when slot service fails`() { + doThrow(RuntimeException("slot service unavailable")) + .`when`(slotService).slotCalculationSummary(501L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/501/slot-calculation-summary") + .retrieve() + .bodyToMono(SlotCalculationSummaryDTO::class.java) + .block()!! + + assertEquals(501L, actual.satelliteId) + assertFalse(actual.calculated) + assertEquals(0L, actual.slotCount) + verify(slotService).slotCalculationSummary(501L) + } + + @Test + fun `satellite initial conditions save endpoint proxies request`() { + val request = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = LocalDateTime.of(2026, 4, 20, 10, 15), + revolution = 123L, + vx = 1.1, + vy = 2.2, + vz = 3.3, + x = 4.4, + y = 5.5, + z = 6.6 + ), + sBall = 0.07, + f81 = 145.2 + ) + val response = SatelliteICDTO( + satelliteId = 501L, + ic = request + ) + doReturn(response).`when`(slotService).saveSatelliteInitialConditions(eq(501L), anyInitialConditions(), eq(true)) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/catalog/satellites/501/initial-conditions?create=true") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(SatelliteICDTO::class.java) + .block()!! + + assertEquals(501L, actual.satelliteId) + assertEquals(123L, actual.ic.orbPoint.revolution) + verify(slotService).saveSatelliteInitialConditions(eq(501L), anyInitialConditions(), eq(true)) + } + + @Test + fun `satellite slot calculation endpoint proxies request`() { + val request = SatelliteSlotProfileDTO( + cycleRevs = 243L, + slotDuration = 10L, + durationCalcDays = 16L, + maxChainLength = 2 + ) + + val status = WebClient.create("http://localhost:$port") + .post() + .uri("/api/catalog/satellites/501/slot-calculation") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchangeToMono { Mono.just(it.statusCode()) } + .block()!! + + assertEquals(HttpStatus.NO_CONTENT, status) + verify(slotService).startSlotCalculation(eq(501L), anySlotProfile()) + } + + 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() +} 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 new file mode 100644 index 0000000..e302828 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/MapControllerComplexPlanTest.kt @@ -0,0 +1,597 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +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 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 +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import space.nstart.pcp.slots_service.service.EarthService +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.DynamicPlanService +import space.nstart.pcp.slots_service.service.SlotService +import space.nstart.pcp.slots_service.service.StationService +import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO +import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationModeDTO +import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanRevolutionModeDTO +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.boot.admin.client.enabled=false" + ] +) +class MapControllerComplexPlanTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var earthService: EarthService + + @MockitoBean + private lateinit var complexMissionService: ComplexMissionService + + @MockitoBean + private lateinit var dynamicPlanService: DynamicPlanService + + @MockitoBean + private lateinit var stationService: StationService + + @MockitoBean + private lateinit var slotService: SlotService + + private val objectMapper = ObjectMapper() + + @Test + fun `local post api requests delegates to earth service facade`() { + val responseId = UUID.fromString("11111111-1111-4111-8111-111111111111") + var capturedRequest: RequestDTO? = null + doAnswer { invocation -> + capturedRequest = invocation.getArgument(0) + RequestDTO( + requestId = responseId, + name = "created request", + importance = 7.5, + contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))", + ) + }.`when`(earthService).addRequest(anyRequest()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/requests") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .bodyValue( + mapOf( + "name" to "map request", + "importance" to 7.5, + "geoType" to "POLYGON", + "contour" to "POLYGON ((0 0, 0 1, 1 1, 0 0))", + ) + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(responseId.toString(), actual["requestId"].asText()) + assertEquals("created request", actual["name"].asText()) + assertEquals("map request", capturedRequest?.name) + assertEquals("POLYGON ((0 0, 0 1, 1 1, 0 0))", capturedRequest?.contour) + verify(earthService).addRequest(anyRequest()) + } + + @Test + fun `satellite slots endpoint proxies interval request`() { + val satelliteId = 101L + val timeStart = LocalDateTime.of(2026, 4, 8, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 8, 12, 0) + doReturn( + listOf( + SlotDTO( + cycle = 2, + satelliteId = satelliteId, + tn = timeStart, + tk = timeStart.plusMinutes(6), + roll = 10.5, + contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))", + revolution = 55L, + revolutionSign = RevolutionSign.ASC, + slotNumber = 9L + ) + ) + ).`when`(slotService).allSlotsByInterval(satelliteId, timeStart, timeStop) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/satellites/$satelliteId/slots?timeStart=$timeStart&timeStop=$timeStop") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual.size()) + assertEquals(satelliteId, actual[0]["satelliteId"].asLong()) + assertEquals(9L, actual[0]["slotNumber"].asLong()) + verify(slotService).allSlotsByInterval(satelliteId, timeStart, timeStop) + } + + @Test + fun `complex plan endpoint proxies process request`() { + val request = ComplexPlanProcessRequestDTO( + intervalStart = LocalDateTime.of(2026, 4, 8, 10, 0), + intervalEnd = LocalDateTime.of(2026, 4, 8, 12, 0), + satelliteIds = listOf(101L, 202L), + countLat = 2, + countLong = 3, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + val response = objectMapper.readTree( + """ + { + "runId": 17, + "status": "COMPLETED", + "durationMs": 60000, + "calculatedModesCount": 4, + "bookedSlotLinksCount": 9 + } + """.trimIndent() + ) + doReturn(response).`when`(complexMissionService).processComplexPlan(request) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/com-plan/process") + .bodyValue( + mapOf( + "intervalStart" to "2026-04-08T10:00:00", + "intervalEnd" to "2026-04-08T12:00:00", + "satelliteIds" to listOf(101, 202), + "countLat" to 2, + "countLong" to 3, + "coverageStrategy" to "CONTINUOUS" + ) + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(17, actual["runId"].asInt()) + assertEquals("COMPLETED", actual["status"].asText()) + assertEquals(60000, actual["durationMs"].asLong()) + assertEquals(4, actual["calculatedModesCount"].asInt()) + verify(complexMissionService).processComplexPlan(request) + } + + @Test + fun `complex plan page is rendered and menu contains link after rva`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/com-plan") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Ком.План")) + assertTrue(body.contains("complex-plan-runs-body")) + assertTrue(body.contains("complex-plan-pagination")) + assertTrue(body.contains("complex-plan-process-form")) + assertTrue(body.contains("complex-plan-satellite-ids")) + assertTrue(body.contains("complex-plan-interval-start")) + assertTrue(body.contains("complex-plan-coverage-strategy")) + assertTrue(body.contains("complex-plan-count-lat")) + assertTrue(body.contains("complex-plan-count-long")) + assertTrue(body.contains("complex-plan-report")) + assertTrue(body.contains("/complex_plan_scripts.js")) + assertTrue(body.indexOf("/rva") < body.indexOf("/com-plan")) + } + + @Test + fun `complex plan script renders added survey count by satellite`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/complex_plan_scripts.js") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Добавлено съемок по спутникам")) + assertTrue(body.contains("addedSurveyRowsBySatellite")) + assertTrue(body.contains("source === 'COMPLAN' || source === 'MIXED'")) + } + + @Test + fun `dynamic plan page is rendered and menu contains separate link`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/dynamic-plan") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Dynamic Plan")) + assertTrue(body.contains("dynamic-plan-form")) + assertTrue(body.contains("dynamic-plan-request-id")) + assertTrue(body.contains("dynamic-plan-satellite-ids")) + assertTrue(body.contains("dynamic-plan-interval-start")) + assertTrue(body.contains("dynamic-plan-interval-end")) + assertTrue(body.contains("dynamic-plan-runs-body")) + assertTrue(body.contains("dynamic-plan-runs-refresh")) + assertTrue(body.contains("/dynamic_plan_map_layers.js")) + assertTrue(body.contains("/dynamic_plan_scripts.js")) + assertTrue(body.contains("/com-plan")) + assertTrue(body.contains("/dynamic-plan")) + } + + @Test + fun `map page loads dynamic plan map layer script`() { + doReturn(Flux.empty()).`when`(earthService).reqs() + doReturn(Flux.empty()).`when`(complexMissionService).satellites() + doReturn(Flux.empty()).`when`(stationService).stations() + + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/map") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("cesiumContainer")) + assertTrue(body.contains("dynamic_plan_map_layers.js")) + assertTrue(body.contains("PcpDynamicPlanMapLayers")) + verify(earthService).reqs() + } + + @Test + fun `dynamic plan map layer keeps only one visible run`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/dynamic_plan_map_layers.js") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("parsed.slice(0, 1)")) + assertTrue(body.contains("writeRuns([normalizedRun])")) + } + + @Test + fun `dynamic plan endpoint proxies calculation request`() { + val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val runId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val request = DynamicPlanCalculationRequestDTO( + requestId = requestId, + satelliteIds = listOf(101L, 202L), + calculationStart = LocalDateTime.of(2026, 4, 8, 10, 0), + calculationEnd = LocalDateTime.of(2026, 4, 8, 12, 0), + calculationMode = DynamicPlanCalculationModeDTO.GREEDY, + revolutionMode = DynamicPlanRevolutionModeDTO.DESC, + filterCoveredRoutes = true + ) + val response = objectMapper.readTree( + """ + { + "runId": "$runId", + "status": "PENDING", + "requestId": "$requestId", + "satelliteIds": [101, 202], + "calculationStart": "2026-04-08T10:00:00", + "calculationEnd": "2026-04-08T12:00:00", + "calculationMode": "GREEDY", + "revolutionMode": "DESC", + "filterCoveredRoutes": true, + "createdAt": "2026-04-08T09:59:59" + } + """.trimIndent() + ) + doReturn(response).`when`(dynamicPlanService).calculate(request) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/dynamic-plan/calc") + .bodyValue( + mapOf( + "requestId" to requestId.toString(), + "satelliteIds" to listOf(101, 202), + "calculationStart" to "2026-04-08T10:00:00", + "calculationEnd" to "2026-04-08T12:00:00", + "calculationMode" to "GREEDY", + "revolutionMode" to "DESC", + "filterCoveredRoutes" to true + ) + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(runId.toString(), actual["runId"].asText()) + assertEquals("PENDING", actual["status"].asText()) + assertEquals("GREEDY", actual["calculationMode"].asText()) + assertEquals("DESC", actual["revolutionMode"].asText()) + assertEquals(true, actual["filterCoveredRoutes"].asBoolean()) + verify(dynamicPlanService).calculate(request) + } + + @Test + fun `dynamic plan runs endpoint proxies history request`() { + val runId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val response = objectMapper.readTree( + """ + [ + { + "runId": "$runId", + "status": "COMPLETED", + "routesCount": 4 + } + ] + """.trimIndent() + ) + doReturn(response).`when`(dynamicPlanService).getRuns(null, 20, 0) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/dynamic-plan/runs?limit=20&offset=0") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual.size()) + assertEquals(runId.toString(), actual[0]["runId"].asText()) + assertEquals("COMPLETED", actual[0]["status"].asText()) + verify(dynamicPlanService).getRuns(null, 20, 0) + } + + @Test + fun `dynamic plan run endpoint proxies status request`() { + val runId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val response = objectMapper.readTree( + """ + { + "runId": "$runId", + "status": "RUNNING", + "routesCount": null + } + """.trimIndent() + ) + doReturn(response).`when`(dynamicPlanService).getRun(runId) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/dynamic-plan/runs/$runId") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(runId.toString(), actual["runId"].asText()) + assertEquals("RUNNING", actual["status"].asText()) + verify(dynamicPlanService).getRun(runId) + } + + @Test + fun `dynamic plan result endpoint proxies result request`() { + val runId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val response = objectMapper.readTree( + """ + { + "status": "COMPLETED", + "requestId": "$requestId", + "satelliteIds": [101, 202], + "missingSatelliteIds": [], + "routesCount": 4, + "durationMs": 5000 + } + """.trimIndent() + ) + doReturn(response).`when`(dynamicPlanService).getResult(runId) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/dynamic-plan/runs/$runId/result") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals("COMPLETED", actual["status"].asText()) + assertEquals(4, actual["routesCount"].asInt()) + verify(dynamicPlanService).getResult(runId) + } + + @Test + fun `dynamic plan routes endpoint proxies routes request`() { + val runId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val response = objectMapper.readTree( + """ + [ + { + "id": 1, + "runId": "$runId", + "requestId": "11111111-1111-4111-8111-111111111111", + "satelliteId": 101, + "startTime": "2026-04-08T10:10:00", + "endTime": "2026-04-08T10:10:30", + "duration": 30.0, + "revolution": 55, + "roll": 11.5, + "contourWkt": "POLYGON ((0 0, 0 1, 1 1, 0 0))" + } + ] + """.trimIndent() + ) + doReturn(response).`when`(dynamicPlanService).getRoutes(runId, 1000, 0) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/dynamic-plan/runs/$runId/routes") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual.size()) + assertEquals(101, actual[0]["satelliteId"].asInt()) + verify(dynamicPlanService).getRoutes(runId, 1000, 0) + } + + @Test + fun `dynamic plan intervals endpoint proxies intervals request`() { + val runId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val response = objectMapper.readTree( + """ + [ + { + "id": 1, + "runId": "$runId", + "requestId": "11111111-1111-4111-8111-111111111111", + "intervalId": "33333333-3333-4333-8333-333333333333", + "regionId": "44444444-4444-4444-8444-444444444444", + "revSign": "ASC", + "contourWkt": "POLYGON ((0 0, 0 1, 1 1, 0 0))", + "observationParametersCount": 12 + } + ] + """.trimIndent() + ) + doReturn(response).`when`(dynamicPlanService).getIntervals(runId, 1000, 0) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/dynamic-plan/runs/$runId/intervals") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual.size()) + assertEquals("ASC", actual[0]["revSign"].asText()) + assertEquals(12, actual[0]["observationParametersCount"].asInt()) + verify(dynamicPlanService).getIntervals(runId, 1000, 0) + } + + @Test + fun `complex plan runs endpoint proxies list request`() { + val response = objectMapper.readTree( + """ + [ + { + "runId": 17, + "status": "COMPLETED", + "intervalStart": "2026-04-08T10:00:00", + "intervalEnd": "2026-04-08T12:00:00", + "satelliteIds": [101, 202] + } + ] + """.trimIndent() + ) + doReturn(response).`when`(complexMissionService).complexPlanRuns() + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/com-plan/runs") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual.size()) + assertEquals(17, actual[0]["runId"].asInt()) + assertEquals("COMPLETED", actual[0]["status"].asText()) + verify(complexMissionService).complexPlanRuns() + } + + @Test + fun `complex plan run endpoint proxies detail request`() { + val response = objectMapper.readTree( + """ + { + "runId": 17, + "status": "COMPLETED", + "durationMs": 60000, + "requestPayload": "{}" + } + """.trimIndent() + ) + doReturn(response).`when`(complexMissionService).complexPlanRun(17L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/com-plan/runs/17") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(17, actual["runId"].asInt()) + assertEquals(60000, actual["durationMs"].asLong()) + verify(complexMissionService).complexPlanRun(17L) + } + + @Test + fun `complex plan run modes endpoint proxies modes request`() { + val response = objectMapper.readTree( + """ + [ + { + "id": 1, + "satelliteId": 101, + "startTime": "2026-04-08T10:00:00", + "endTime": "2026-04-08T10:05:00" + } + ] + """.trimIndent() + ) + doReturn(response).`when`(complexMissionService).complexPlanRunModes(17L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/com-plan/runs/17/modes") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(1, actual.size()) + assertEquals(101, actual[0]["satelliteId"].asInt()) + verify(complexMissionService).complexPlanRunModes(17L) + } + + @Test + fun `complex plan run delete endpoint proxies delete request`() { + WebClient.create("http://localhost:$port") + .delete() + .uri("/api/com-plan/runs/17") + .retrieve() + .toBodilessEntity() + .block() + + verify(complexMissionService).deleteComplexPlanRun(17L) + } + + private fun anyRequest(): RequestDTO { + any(RequestDTO::class.java) + return RequestDTO() + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/RvaControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/RvaControllerTest.kt new file mode 100644 index 0000000..31b527d --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/RvaControllerTest.kt @@ -0,0 +1,166 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Flux +import space.nstart.pcp.pcp_types_lib.dto.ballistics.DurationOnRevDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.IntervalDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO +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.satellite.SatelliteInfoDTO +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.SlotService +import space.nstart.pcp.slots_service.service.StationService +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.boot.admin.client.enabled=false" + ] +) +class RvaControllerTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var complexMissionService: ComplexMissionService + + @MockitoBean + private lateinit var stationService: StationService + + @MockitoBean + private lateinit var slotService: SlotService + + @Test + fun `rva page is rendered`() { + doReturn(Flux.just(SatelliteInfoDTO(noradId = 56756L, name = "Kondor"))).`when`(complexMissionService).satellites() + doReturn(Flux.just(sampleStation())).`when`(stationService).stations() + + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/rva") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("ЗРВ")) + assertTrue(body.contains("Рассчитать")) + assertTrue(body.contains("Скачать CSV")) + assertTrue(body.contains("Суммарная длительность на витке")) + assertTrue(body.contains("rva-station-label")) + } + + @Test + fun `rva common endpoint proxies request`() { + val station = sampleStation() + val response = listOf( + RadioVisibilityAreaDTO( + stationId = 1L, + revolution = 42L, + onStart = TargetPositionDTO(time = LocalDateTime.of(2026, 4, 17, 12, 0)), + onMaximum = TargetPositionDTO(time = LocalDateTime.of(2026, 4, 17, 12, 3)), + onStop = TargetPositionDTO(time = LocalDateTime.of(2026, 4, 17, 12, 5)), + duration = 300.0 + ) + ) + doReturn(response).`when`(slotService).rvaCommon(eq(56756L), eq(2L), anyStationList()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/rva/common/56756/2") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(listOf(station)) + .retrieve() + .bodyToFlux(RadioVisibilityAreaDTO::class.java) + .collectList() + .block()!! + + assertEquals(1, actual.size) + assertEquals(42L, actual.first().revolution) + verify(slotService).rvaCommon(eq(56756L), eq(2L), anyStationList()) + } + + @Test + fun `rva merge endpoint proxies request`() { + val station = sampleStation() + val response = listOf( + IntervalDTO( + begin = LocalDateTime.of(2026, 4, 17, 12, 0), + end = LocalDateTime.of(2026, 4, 17, 12, 10), + durationSec = 600.0 + ) + ) + doReturn(response).`when`(slotService).rvaMerge(eq(56756L), eq(2L), anyStationList()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/rva/merge/56756/2") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(listOf(station)) + .retrieve() + .bodyToFlux(IntervalDTO::class.java) + .collectList() + .block()!! + + assertEquals(1, actual.size) + assertEquals(600.0, actual.first().durationSec) + verify(slotService).rvaMerge(eq(56756L), eq(2L), anyStationList()) + } + + @Test + fun `rva merge on rev endpoint proxies request`() { + val station = sampleStation() + val response = listOf( + DurationOnRevDTO( + revolution = 42L, + durationSec = 780.0 + ) + ) + doReturn(response).`when`(slotService).rvaMergeOnRev(eq(56756L), eq(2L), anyStationList()) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/api/rva/merge-on-rev/56756/2") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(listOf(station)) + .retrieve() + .bodyToFlux(DurationOnRevDTO::class.java) + .collectList() + .block()!! + + assertEquals(1, actual.size) + assertEquals(42L, actual.first().revolution) + assertEquals(780.0, actual.first().durationSec) + verify(slotService).rvaMergeOnRev(eq(56756L), eq(2L), anyStationList()) + } + + private fun sampleStation() = StationDTO( + number = 1, + name = "Station 1", + position = PositionDTO( + lat = 55.75, + long = 37.62, + height = 200.0 + ), + elevationMin = 5.0, + elevationMax = 90.0 + ) + + private fun anyStationList(): List = anyList() ?: emptyList() +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerCoverageSchemeTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerCoverageSchemeTest.kt new file mode 100644 index 0000000..553dad2 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerCoverageSchemeTest.kt @@ -0,0 +1,150 @@ +package space.nstart.pcp.slots_service.rest + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +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 space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.slots_service.czml.CZMLColor +import space.nstart.pcp.slots_service.czml.CZMLEntity +import space.nstart.pcp.slots_service.czml.CZMLMaterial +import space.nstart.pcp.slots_service.czml.CZMLPolygon +import space.nstart.pcp.slots_service.czml.CZMLPosition +import space.nstart.pcp.slots_service.service.CoverageSchemeModeRowDTO +import space.nstart.pcp.slots_service.service.CoverageSchemeService +import space.nstart.pcp.slots_service.service.CoverageSchemeUiResponseDTO +import space.nstart.pcp.slots_service.service.EarthService +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.SlotService +import space.nstart.pcp.slots_service.service.StationService +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.boot.admin.client.enabled=false" + ] +) +class RequestRestControllerCoverageSchemeTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var earthService: EarthService + + @MockitoBean + private lateinit var complexMissionService: ComplexMissionService + + @MockitoBean + private lateinit var stationService: StationService + + @MockitoBean + private lateinit var slotService: SlotService + + @MockitoBean + private lateinit var coverageSchemeService: CoverageSchemeService + + private val objectMapper = ObjectMapper() + + @Test + fun `request coverage scheme endpoint returns map and table payload`() { + val requestId = "req-scheme" + val timeStart = LocalDateTime.of(2026, 4, 14, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 14, 12, 0) + val satellites = listOf(11L, 22L) + val rollStepDegrees = 2.5 + val minimumTechnologyOverlap = 0.1 + val strictContinuousCoverage = false + val allowPartialCoverage = true + val orientationToleranceDegrees = 7.5 + val includeDebugInfo = false + + doReturn( + CoverageSchemeUiResponseDTO( + cov = listOf( + CZMLEntity("document", "req_cov_scheme_$requestId", "1.1"), + CZMLEntity( + id = "coverage_mode_${requestId}_11_1", + name = "Режим схемы покрытия", + polygon = CZMLPolygon( + positions = CZMLPosition(cartographicDegrees = listOf(30.0, 10.0, 0.0, 40.0, 40.0, 0.0, 20.0, 40.0, 0.0, 30.0, 10.0, 0.0)), + outlineColor = CZMLColor(12, 34, 56, 255), + material = CZMLMaterial(12, 34, 56, 150) + ) + ) + ), + slots = listOf( + CoverageSchemeModeRowDTO( + entityId = "coverage_mode_${requestId}_11_1", + sequence = 1, + satelliteId = 11L, + tn = timeStart, + tk = timeStart.plusMinutes(5), + roll = 12.5, + revolutionSign = RevolutionSign.ASC, + contour = "POLYGON((30 10, 40 40, 20 40, 30 10))", + selectionReason = "seed from WEST" + ) + ) + ) + ).`when`(coverageSchemeService).requestCoverageScheme( + requestId, + timeStart, + timeStop, + satellites, + rollStepDegrees, + minimumTechnologyOverlap, + strictContinuousCoverage, + allowPartialCoverage, + orientationToleranceDegrees, + includeDebugInfo + ) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri( + "/requests/req-with-coverage-scheme/$requestId" + + "?tn=2026-04-14T10:00:00" + + "&tk=2026-04-14T12:00:00" + + "&rollStepDegrees=2.5" + + "&minimumTechnologyOverlap=0.1" + + "&strictContinuousCoverage=false" + + "&allowPartialCoverage=true" + + "&orientationToleranceDegrees=7.5" + + "&includeDebugInfo=false" + + "&satellites=11" + + "&satellites=22" + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(2, actual["cov"].size()) + assertEquals(1, actual["slots"].size()) + assertEquals(1, actual["slots"][0]["sequence"].asInt()) + assertEquals(11L, actual["slots"][0]["satelliteId"].asLong()) + assertNotNull(actual["slots"][0]["entityId"]) + verify(coverageSchemeService).requestCoverageScheme( + requestId, + timeStart, + timeStop, + satellites, + rollStepDegrees, + minimumTechnologyOverlap, + strictContinuousCoverage, + allowPartialCoverage, + orientationToleranceDegrees, + includeDebugInfo + ) + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerCoverageSunTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerCoverageSunTest.kt new file mode 100644 index 0000000..b3f169e --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerCoverageSunTest.kt @@ -0,0 +1,144 @@ +package space.nstart.pcp.slots_service.rest + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +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.RevolutionSign +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.satellite.SatelliteInfoDTO +import space.nstart.pcp.slots_service.service.EarthService +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.SlotService +import space.nstart.pcp.slots_service.service.StationService +import tools.jackson.databind.ObjectMapper +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.boot.admin.client.enabled=false" + ] +) +class RequestRestControllerCoverageSunTest { + + @LocalServerPort + private var port: Int = 0 + + @MockitoBean + private lateinit var earthService: EarthService + + @MockitoBean + private lateinit var complexMissionService: ComplexMissionService + + @MockitoBean + private lateinit var stationService: StationService + + @MockitoBean + private lateinit var slotService: SlotService + + private val objectMapper = ObjectMapper() + + @Test + fun `request coverage forwards sun parameter to slot service`() { + val requestId = "req-1" + val timeStart = LocalDateTime.of(2026, 4, 9, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 9, 12, 0) + val satellites = listOf(11L, 22L) + val sun = -45.5 + + doReturn(Flux.empty()) + .`when`(slotService) + .slots(requestId, timeStart, timeStop, RevolutionSign.ASC, true, satellites, sun) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri( + "/requests/req-with-covs/$requestId" + + "?tn=2026-04-09T10:00:00" + + "&tk=2026-04-09T12:00:00" + + "&sign=ASC" + + "&cov=true" + + "&sun=-45.5" + + "&satellites=11" + + "&satellites=22" + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + assertEquals(0, actual["slots"].size()) + verify(slotService).slots(requestId, timeStart, timeStop, RevolutionSign.ASC, true, satellites, sun) + } + + @Test + fun `request coverage uses satellite color for slot polygons`() { + val requestId = "req-color" + val timeStart = LocalDateTime.of(2026, 4, 9, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 9, 12, 0) + val slot = SlotDTO( + cycle = 7, + satelliteId = 11L, + tn = timeStart, + tk = timeStart.plusMinutes(10), + roll = 12.5, + contour = "POLYGON((30 10, 40 40, 20 40, 10 20, 30 10))", + revolution = 3, + revolutionSign = RevolutionSign.ASC, + slotNumber = 4, + state = SlotStatus.BOOKED + ) + + doReturn(Flux.just(slot)) + .`when`(slotService) + .slots(requestId, timeStart, timeStop, null, true, null, null) + + doReturn( + Mono.just( + SatelliteInfoDTO( + noradId = 11L, + name = "SAT-11", + red = 12, + green = 34, + blue = 56, + scanTLE = false + ) + ) + ) + .`when`(complexMissionService) + .satellite(11L) + + val actual = WebClient.create("http://localhost:$port") + .post() + .uri( + "/requests/req-with-covs/$requestId" + + "?tn=2026-04-09T10:00:00" + + "&tk=2026-04-09T12:00:00" + + "&cov=true" + ) + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + val slotEntity = actual["cov"]?.get(1) + assertNotNull(slotEntity) + assertEquals(listOf(12, 34, 56, 255), slotEntity["polygon"]["outlineColor"]["rgba"].map { it.asInt() }) + assertEquals( + listOf(12, 34, 56, 150), + slotEntity["polygon"]["material"]["solidColor"]["color"]["rgba"].map { it.asInt() } + ) + verify(complexMissionService).satellite(11L) + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerSatFldTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerSatFldTest.kt new file mode 100644 index 0000000..742f2c3 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/rest/RequestRestControllerSatFldTest.kt @@ -0,0 +1,93 @@ +package space.nstart.pcp.slots_service.rest + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.springframework.test.util.ReflectionTestUtils +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO +import space.nstart.pcp.slots_service.czml.CZMLEntity +import space.nstart.pcp.slots_service.service.CZMLService +import space.nstart.pcp.slots_service.service.ComplexMissionService +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RequestRestControllerSatFldTest { + + @Test + fun `plan endpoint forwards runId to satellite plan`() { + val czmlService = mock(CZMLService::class.java) + val controller = RequestRestController() + val timeStart = LocalDateTime.of(2026, 4, 22, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 22, 12, 0) + + doReturn(emptyList()).`when`(czmlService) + .showSatPlan(101L, timeStart, timeStop, 17L) + ReflectionTestUtils.setField(controller, "czmlService", czmlService) + + val result = controller.plan( + id = 101L, + tn = timeStart, + tk = timeStop, + runId = 17L + ) + + assertTrue(result.none()) + verify(czmlService).showSatPlan(101L, timeStart, timeStop, 17L) + } + + @Test + fun `sat endpoint forwards time interval to satFLD`() { + val czmlService = mock(CZMLService::class.java) + val controller = RequestRestController() + val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0) + + doReturn(emptyList()).`when`(czmlService) + .satFLD(101L, false, true, true, true, timeStart, timeStop) + ReflectionTestUtils.setField(controller, "czmlService", czmlService) + + val result = controller.sat( + id = 101L, + view = false, + orbit = true, + sw = true, + fl = true, + tn = timeStart, + tk = timeStop + ) + + assertTrue(result.none()) + verify(czmlService).satFLD(101L, false, true, true, true, timeStart, timeStop) + } + + @Test + fun `satellite plan czml does not depend on slots service`() { + val service = CZMLService() + val complexMissionService = mock(ComplexMissionService::class.java) + val slotService = mock(SlotService::class.java) + val timeStart = LocalDateTime.of(2026, 5, 18, 15, 20) + val timeStop = LocalDateTime.of(2026, 6, 1, 15, 20) + + doReturn(Mono.just(SatelliteInfoDTO(noradId = 31L, name = "SAT-31"))) + .`when`(complexMissionService) + .satellite(31L) + doReturn(Flux.empty()) + .`when`(complexMissionService) + .plan(31L, timeStart, timeStop, null) + + ReflectionTestUtils.setField(service, "complexMissionService", complexMissionService) + ReflectionTestUtils.setField(service, "slotService", slotService) + + val result = service.showSatPlan(31L, timeStart, timeStop).toList() + + assertEquals("document", result.first().id) + verifyNoInteractions(slotService) + } +} 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 new file mode 100644 index 0000000..17b191e --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BallisticsServiceTest.kt @@ -0,0 +1,117 @@ +package space.nstart.pcp.slots_service.service + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +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.OrbPointDTO +import java.net.InetSocketAddress +import java.net.URI +import java.time.LocalDateTime +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class BallisticsServiceTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `sat data methods forward time interval to ballistics service`() { + val requests = CopyOnWriteArrayList() + val serverUrl = startServer(requests) + val service = createService(serverUrl) + val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0) + + service.ascNodes(101L, timeStart, timeStop).collectList().block() + service.flightLine(101L, timeStart, timeStop).collectList().block() + service.points(101L, timeStart, timeStop).collectList().block() + val availability = service.orbitAvailability() + val exactPoint = service.exactTimePoint(101L, timeStart) + + assertEquals( + listOf( + "/api/satellites/101/asc-node", + "/api/satellites/101/flight-line", + "/api/satellites/101/orbit", + "/api/satellites/orbit/availability", + "/api/satellites/101/extract-time" + ), + requests.map { it.path } + ) + assertEquals( + listOf( + "time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00", + "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 + ), + requests.map { it.query } + ) + assertEquals(1, availability.size) + assertEquals(101L, availability.single().satelliteId) + assertNotNull(exactPoint) + assertEquals(timeStart, exactPoint.time) + } + + private fun createService(serverUrl: String): BallisticsService { + val provider = object : ObjectProvider { + override fun getObject(vararg args: Any?): WebClient.Builder = WebClient.builder() + override fun getObject(): WebClient.Builder = WebClient.builder() + override fun getIfAvailable(): WebClient.Builder = WebClient.builder() + override fun getIfUnique(): WebClient.Builder = WebClient.builder() + override fun iterator(): MutableIterator = mutableListOf().iterator() + } + val service = BallisticsService(provider) + ReflectionTestUtils.setField(service, "url", serverUrl) + return service + } + + private fun startServer(requests: MutableList): String { + val startedServer = HttpServer.create(InetSocketAddress(0), 0) + listOf( + "/api/satellites/101/asc-node", + "/api/satellites/101/flight-line", + "/api/satellites/101/orbit" + ).forEach { path -> + startedServer.createContext(path) { exchange -> + requests.add(exchange.requestURI) + respond(exchange, "[]") + } + } + startedServer.createContext("/api/satellites/orbit/availability") { exchange -> + requests.add(exchange.requestURI) + respond( + exchange, + """[{"satelliteId":101,"timeStart":"2026-04-21T10:00:00","timeStop":"2026-04-21T12:00:00"}]""" + ) + } + startedServer.createContext("/api/satellites/101/extract-time") { exchange -> + requests.add(exchange.requestURI) + respond( + exchange, + """[{"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.start() + server = startedServer + return "http://localhost:${startedServer.address.port}" + } + + private fun respond(exchange: HttpExchange, body: String) { + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = body.toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { output -> output.write(bytes) } + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/ComplexMissionServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/ComplexMissionServiceTest.kt new file mode 100644 index 0000000..4534eec --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/ComplexMissionServiceTest.kt @@ -0,0 +1,165 @@ +package space.nstart.pcp.slots_service.service + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +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.slots_service.configuration.WebClientConfig +import java.net.InetSocketAddress +import java.net.URI +import java.time.LocalDateTime +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals + +class ComplexMissionServiceTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `complex plan run methods call complex mission endpoints`() { + val requests = CopyOnWriteArrayList() + val methods = CopyOnWriteArrayList() + val serverUrl = startServer(requests, methods) + val service = createService(serverUrl) + + val runs = service.complexPlanRuns() + val run = service.complexPlanRun(17L) + val modes = service.complexPlanRunModes(17L) + service.deleteComplexPlanRun(17L) + + assertEquals(1, runs.size()) + assertEquals(17, runs[0]["runId"].asInt()) + assertEquals(17, run["runId"].asInt()) + assertEquals(0, modes.size()) + assertEquals( + listOf( + "/api/com-plan/runs", + "/api/com-plan/runs/17", + "/api/com-plan/runs/17/modes", + "/api/com-plan/runs/17" + ), + requests.map { it.path } + ) + assertEquals(listOf("GET", "GET", "GET", "DELETE"), methods) + } + + @Test + fun `complex plan run modes supports report payloads larger than previous webclient buffer`() { + val itemPayload = "x".repeat(1000) + val itemCount = 22_000 + val modesBody = buildString { + append("[") + repeat(itemCount) { index -> + if (index > 0) { + append(",") + } + append("""{"slotNumber":$index,"payload":"$itemPayload"}""") + } + append("]") + } + val serverUrl = startServer( + requests = CopyOnWriteArrayList(), + modesBody = modesBody + ) + val service = createService(serverUrl) { + WebClientConfig(32 * 1024 * 1024).webClientBuilder() + } + + val modes = service.complexPlanRunModes(17L) + + assertEquals(itemCount, modes.size()) + assertEquals(itemPayload.length, modes[0]["payload"].asText().length) + } + + @Test + fun `plan calls satellite scoped modes endpoint with runId`() { + val requests = CopyOnWriteArrayList() + val serverUrl = startServer(requests) + val service = createService(serverUrl) + val intervalStart = LocalDateTime.of(2026, 4, 22, 10, 0) + val intervalEnd = LocalDateTime.of(2026, 4, 22, 12, 0) + + val response = service.plan(101L, intervalStart, intervalEnd, 17L) + .collectList() + .block() + .orEmpty() + + assertEquals(emptyList(), response) + assertEquals("/api/satellites/101/modes", requests.single().path) + assertEquals( + "intervalStart=2026-04-22T10:00&intervalEnd=2026-04-22T12:00&runId=17", + requests.single().rawQuery + ) + } + + private fun createService( + serverUrl: String, + builderFactory: () -> WebClient.Builder = { WebClient.builder() } + ): ComplexMissionService { + val provider = object : ObjectProvider { + override fun getObject(vararg args: Any?): WebClient.Builder = builderFactory() + override fun getObject(): WebClient.Builder = builderFactory() + override fun getIfAvailable(): WebClient.Builder = builderFactory() + override fun getIfUnique(): WebClient.Builder = builderFactory() + override fun iterator(): MutableIterator = mutableListOf().iterator() + } + val service = ComplexMissionService(provider) + ReflectionTestUtils.setField(service, "complexMissionUrl", serverUrl) + ReflectionTestUtils.setField(service, "satelliteCatalogUrl", serverUrl) + return service + } + + private fun startServer( + requests: MutableList, + methods: MutableList = mutableListOf(), + modesBody: String = "[]" + ): String { + val startedServer = HttpServer.create(InetSocketAddress(0), 0) + startedServer.createContext("/api/com-plan/runs") { exchange -> + requests.add(exchange.requestURI) + methods.add(exchange.requestMethod) + if (exchange.requestMethod == "DELETE") { + respondNoContent(exchange) + } else { + respond( + exchange, + if (exchange.requestURI.path.endsWith("/modes")) { + modesBody + } else if (exchange.requestURI.path.endsWith("/17")) { + """{"runId":17,"status":"COMPLETED"}""" + } else { + """[{"runId":17,"status":"COMPLETED"}]""" + } + ) + } + } + startedServer.createContext("/api/satellites/101/modes") { exchange -> + requests.add(exchange.requestURI) + methods.add(exchange.requestMethod) + respond(exchange, "[]") + } + startedServer.start() + server = startedServer + return "http://localhost:${startedServer.address.port}" + } + + private fun respond(exchange: HttpExchange, body: String) { + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = body.toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { output -> output.write(bytes) } + } + + private fun respondNoContent(exchange: HttpExchange) { + exchange.sendResponseHeaders(204, -1) + exchange.close() + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeServiceTest.kt new file mode 100644 index 0000000..ef1e07f --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeServiceTest.kt @@ -0,0 +1,212 @@ +package space.nstart.pcp.slots_service.service + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +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.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import tools.jackson.databind.ObjectMapper +import java.net.InetSocketAddress +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class CoverageSchemeServiceTest { + + private val objectMapper = ObjectMapper() + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `requestCoverageScheme maps coverageScheme slots to rows and czml`() { + val timeStart = LocalDateTime.of(2026, 4, 14, 10, 0) + val timeStop = LocalDateTime.of(2026, 4, 14, 12, 0) + val serverUrl = startServer( + mapOf( + "/v1/requests/req-1/with-cells" to stringHandler(200, requestWithCellsJson()), + "/api/coverage-schemes/calculate" to jsonHandler( + CoverageSchemeResponseDTO( + targetPolygonWkt = "POLYGON ((30 10, 40 40, 20 40, 30 10))", + coverageScheme = listOf( + SurveySlotDTO( + satelliteId = 11L, + tn = timeStart, + tk = timeStart.plusMinutes(5), + roll = 0.0, + contour = "POLYGON((30 10, 40 40, 20 40, 30 10))", + revolutionSign = RevolutionSign.ASC + ), + SurveySlotDTO( + satelliteId = 11L, + tn = timeStart, + tk = timeStart.plusMinutes(5), + roll = 0.0, + contour = "POLYGON((31 11, 32 12, 30 12, 31 11))", + revolutionSign = RevolutionSign.ASC + ) + ) + ) + ), + "/api/satellites/11" to jsonHandler( + SatelliteDTO( + id = 11L, + code = "SAT-11", + name = "Sat-11", + typeCode = "TEST", + visualization = SatelliteVisualizationDTO( + red = 12, + green = 34, + blue = 56 + ), + observationProfile = SatelliteObservationProfileDTO() + ) + ) + ) + ) + + val service = createService(serverUrl) + + val response = service.requestCoverageScheme( + requestId = "req-1", + timeStart = timeStart, + timeStop = timeStop, + satelliteIds = listOf(11L, 22L), + rollStepDegrees = 2.5, + minimumTechnologyOverlap = 0.1, + strictContinuousCoverage = false, + allowPartialCoverage = true, + orientationToleranceDegrees = 7.5, + includeDebugInfo = false + ) + + assertEquals(2, response.slots.size) + assertEquals(3, response.cov.size) + assertEquals("document", response.cov.first().id) + assertEquals(1, response.slots.first().sequence) + assertEquals(2, response.slots.last().sequence) + assertEquals(11L, response.slots.first().satelliteId) + assertEquals(0.0, response.slots.first().roll) + assertEquals(0.0, response.slots.last().roll) + assertEquals(RevolutionSign.ASC, response.slots.first().revolutionSign) + assertNotNull(response.cov[1].polygon) + assertEquals(listOf(12, 34, 56, 255), response.cov[1].polygon?.outlineColor?.rgba?.toList()) + } + + @Test + fun `requestCoverageScheme converts backend error to CustomErrorException`() { + val serverUrl = startServer( + mapOf( + "/v1/requests/req-1/with-cells" to stringHandler(200, requestWithCellsJson()), + "/api/coverage-schemes/calculate" to stringHandler(400, """{"message":"coverage failed"}""") + ) + ) + + val service = createService(serverUrl) + + val error = assertFailsWith { + service.requestCoverageScheme( + requestId = "req-1", + timeStart = LocalDateTime.of(2026, 4, 14, 10, 0), + timeStop = LocalDateTime.of(2026, 4, 14, 12, 0), + satelliteIds = listOf(11L), + rollStepDegrees = null, + minimumTechnologyOverlap = null, + strictContinuousCoverage = null, + allowPartialCoverage = null, + orientationToleranceDegrees = null, + includeDebugInfo = null + ) + } + + assertEquals("coverage failed", error.message) + } + + private fun createService(serverUrl: String): CoverageSchemeService { + val provider = object : ObjectProvider { + override fun getObject(vararg args: Any?): WebClient.Builder = WebClient.builder() + override fun getObject(): WebClient.Builder = WebClient.builder() + override fun getIfAvailable(): WebClient.Builder = WebClient.builder() + override fun getIfUnique(): WebClient.Builder = WebClient.builder() + override fun iterator(): MutableIterator = mutableListOf().iterator() + } + val service = CoverageSchemeService(provider) + val earthService = EarthService(provider) + val complexMissionService = ComplexMissionService(provider) + + ReflectionTestUtils.setField(service, "earthService", earthService) + ReflectionTestUtils.setField(service, "complexMissionService", complexMissionService) + ReflectionTestUtils.setField(service, "url", serverUrl) + ReflectionTestUtils.setField(earthService, "url", serverUrl) + ReflectionTestUtils.setField(complexMissionService, "complexMissionUrl", serverUrl) + ReflectionTestUtils.setField(complexMissionService, "satelliteCatalogUrl", serverUrl) + return service + } + + private fun startServer(handlers: Map): String { + val startedServer = HttpServer.create(InetSocketAddress(0), 0) + handlers.forEach { (path, handler) -> + startedServer.createContext(path, handler) + } + startedServer.start() + server = startedServer + return "http://localhost:${startedServer.address.port}" + } + + private fun jsonHandler(body: Any) = HttpHandler { exchange -> + respond(exchange, 200, objectMapper.writeValueAsString(body)) + } + + private fun stringHandler(status: Int, body: String) = HttpHandler { exchange -> + respond(exchange, status, body) + } + + private fun requestWithCellsJson(): String = + """ + { + "request": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "coverage scheme request", + "status": "ACCEPTED", + "surveyType": "OPTICS", + "geometry": "POLYGON ((30 10, 40 40, 20 40, 30 10))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-02T00:00:00Z", + "kpp": [], + "highPriorityTransmit": false, + "optics": null, + "rsa": null, + "coverage": { + "requiredPercent": 100.0, + "currentPercent": 0.0 + }, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "deletedAt": null + }, + "cells": [] + } + """.trimIndent() + + private fun respond(exchange: HttpExchange, status: Int, body: String) { + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { output -> output.write(bytes) } + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/EarthServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/EarthServiceTest.kt new file mode 100644 index 0000000..7c004b1 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/EarthServiceTest.kt @@ -0,0 +1,543 @@ +package space.nstart.pcp.slots_service.service + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.ObjectProvider +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO +import tools.jackson.databind.ObjectMapper +import java.net.InetSocketAddress +import java.net.URI +import java.nio.charset.StandardCharsets +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class EarthServiceTest { + private val objectMapper = ObjectMapper() + + @Test + fun `cells calls v1 cells with min importance`() { + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf(cellsResponse()) + ) + + service.cells(3.5).toList() + + assertEquals("/v1/cells", requestedUris.single().path) + assertEquals("minImportance=3.5&page=0&size=500", requestedUris.single().query) + assertFalse(requestedUris.single().path.startsWith("/api/earth-grid")) + } + + @Test + fun `cells maps response wrapper items to earth cell dto`() { + val service = earthService( + responses = mutableListOf( + cellsResponse( + """ + { + "items": [ + { + "cellNum": 42, + "latitude": 55.0, + "longitude": 37.0, + "importance": 10.0, + "contour": "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))" + } + ], + "page": 0, + "size": 500, + "totalItems": 1, + "totalPages": 1 + } + """.trimIndent() + ) + ) + ) + + val cell = service.cells(0.0).single() + + assertEquals(42L, cell.id) + assertEquals(42L, cell.num) + assertEquals(55.0, cell.latitude) + assertEquals(37.0, cell.longitude) + assertEquals(10.0, cell.importance) + assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", cell.contour) + } + + @Test + fun `cells does not pass count lat and count long to v1 cells`() { + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf(cellsResponse()) + ) + + service.cells(1.0, countLat = 2, countLong = 3).toList() + + val query = requestedUris.single().query + assertFalse(query.contains("countLat")) + assertFalse(query.contains("countLong")) + } + + @Test + fun `cells fetches all pages from v1 cells`() { + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + cellsResponse( + """ + { + "items": [ + {"cellNum": 1, "latitude": 1.0, "longitude": 2.0, "importance": 3.0, "contour": "POLYGON EMPTY"} + ], + "page": 0, + "size": 500, + "totalItems": 2, + "totalPages": 2 + } + """.trimIndent() + ), + cellsResponse( + """ + { + "items": [ + {"cellNum": 2, "latitude": 4.0, "longitude": 5.0, "importance": 6.0, "contour": "POLYGON EMPTY"} + ], + "page": 1, + "size": 500, + "totalItems": 2, + "totalPages": 2 + } + """.trimIndent() + ), + ) + ) + + val cells = service.cells(0.0).toList() + + assertEquals(listOf(1L, 2L), cells.map { cell -> cell.num }) + assertEquals(listOf("page=0", "page=1"), requestedUris.map { uri -> uri.query.substringAfter("minImportance=0.0&").substringBefore("&size") }) + } + + @Test + fun `reqs calls v1 requests with page and size instead of legacy api requests`() { + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf(requestsResponse()) + ) + + service.reqs().collectList().block() + + assertEquals("/v1/requests", requestedUris.single().path) + assertEquals("page=0&size=500", requestedUris.single().query) + assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" }) + } + + @Test + fun `reqs unwraps request list items and maps full request geometry to request dto`() { + val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val service = earthService( + responses = mutableListOf( + requestsResponse( + """ + { + "items": [ + { + "id": "$requestId", + "name": "summary request name", + "status": "ACCEPTED", + "surveyType": "OPTICS", + "importance": 7.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-02T00:00:00Z", + "kpp": [1, 2], + "highPriorityTransmit": false, + "coveragePercent": 0.0 + } + ], + "page": 0, + "size": 500, + "totalItems": 1, + "totalPages": 1 + } + """.trimIndent() + ), + requestDetailsResponse( + """ + { + "id": "$requestId", + "name": "real request name", + "geometry": "POLYGON ((30 10, 40 40, 20 40, 30 10))", + "importance": 7.0, + "status": "ACCEPTED", + "surveyType": "OPTICS" + } + """.trimIndent() + ), + ) + ) + + val request = service.reqs().single().block()!! + + assertEquals(requestId, request.requestId) + assertEquals("real request name", request.name) + assertFalse(request.name == requestId.toString()) + assertEquals(7.0, request.importance) + assertEquals("POLYGON ((30 10, 40 40, 20 40, 30 10))", request.contour) + } + + @Test + fun `addRequest posts new v1 create request and returns legacy request dto`() { + val responseId = UUID.fromString("33333333-3333-4333-8333-333333333333") + val responseName = "created request" + val responseImportance = 8.25 + val requestBody = AtomicReference() + val requestMethod = AtomicReference() + val requestPath = AtomicReference() + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/") { exchange -> + requestMethod.set(exchange.requestMethod) + requestPath.set(exchange.requestURI.path) + requestBody.set(exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8)) + + val response = """ + { + "id": "$responseId", + "name": "$responseName", + "status": "ACCEPTED", + "surveyType": "OPTICS", + "importance": $responseImportance, + "message": "Заявка успешно создана" + } + """.trimIndent().toByteArray(StandardCharsets.UTF_8) + + exchange.responseHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + exchange.sendResponseHeaders(HttpStatus.CREATED.value(), response.size.toLong()) + exchange.responseBody.use { body -> body.write(response) } + } + server.start() + + try { + val sourceRequest = RequestDTO( + name = "map request", + importance = 7.5, + contour = "POLYGON ((30 10, 40 40, 20 40, 30 10))", + ) + val service = EarthService(provider(WebClient.builder()), url = "http://localhost:${server.address.port}") + + val result = service.addRequest(sourceRequest)!! + + assertEquals(responseId, result.requestId) + assertEquals(responseImportance, result.importance) + assertEquals(sourceRequest.contour, result.contour) + assertEquals(responseName, result.name) + assertEquals("POST", requestMethod.get()) + assertEquals("/v1/requests", requestPath.get()) + assertFalse(requestPath.get() == "/api/requests") + + val bodyText = assertNotNull(requestBody.get()) + val body = objectMapper.readTree(bodyText) + assertNotNull(UUID.fromString(body["id"].asText())) + assertEquals(sourceRequest.name, body["name"].asText()) + assertEquals(sourceRequest.contour, body["geometry"].asText()) + assertEquals(sourceRequest.importance, body["importance"].asDouble()) + assertEquals("2026-01-01T00:00:00Z", body["beginDateTime"].asText()) + assertEquals("2026-12-31T23:59:59Z", body["endDateTime"].asText()) + assertEquals(0, body["kpp"].size()) + assertFalse(body["highPriorityTransmit"].asBoolean()) + assertEquals("PANCHROMATIC", body["optics"]["resultType"].asText()) + assertEquals(5.0, body["optics"]["resolution"].asDouble()) + assertEquals(10.0, body["optics"]["sunAngleMin"].asDouble()) + assertEquals(90.0, body["optics"]["sunAngleMax"].asDouble()) + assertEquals(100.0, body["optics"]["clouds"].asDouble()) + assertTrue(!bodyText.contains("app_type")) + assertTrue(!bodyText.contains("appType")) + assertTrue(!bodyText.contains("AppType")) + assertTrue(!bodyText.contains("requestType")) + } finally { + server.stop(0) + } + } + + @Test + fun `reqs fetches all pages and loads full request details for every summary`() { + val firstId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val secondId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + requestsResponse( + """ + { + "items": [ + { + "id": "$firstId", + "name": "first request", + "status": "ACCEPTED", + "surveyType": "OPTICS", + "importance": 1.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-02T00:00:00Z", + "kpp": [], + "highPriorityTransmit": false + } + ], + "page": 0, + "size": 500, + "totalItems": 2, + "totalPages": 2 + } + """.trimIndent() + ), + requestsResponse( + """ + { + "items": [ + { + "id": "$secondId", + "name": "second request", + "status": "ACCEPTED", + "surveyType": "RSA", + "importance": 2.0, + "beginDateTime": "2026-01-03T00:00:00Z", + "endDateTime": "2026-01-04T00:00:00Z", + "kpp": [3], + "highPriorityTransmit": true + } + ], + "page": 1, + "size": 500, + "totalItems": 2, + "totalPages": 2 + } + """.trimIndent() + ), + requestDetailsResponse( + """ + { + "id": "$firstId", + "name": "first request", + "geometry": "POLYGON EMPTY", + "importance": 1.0 + } + """.trimIndent() + ), + requestDetailsResponse( + """ + { + "id": "$secondId", + "name": "second request", + "geometry": "POLYGON ((0 0, 1 0, 1 1, 0 0))", + "importance": 2.0 + } + """.trimIndent() + ), + ) + ) + + val requests = service.reqs().collectList().block()!! + + assertEquals(listOf(firstId, secondId), requests.map { request -> request.requestId }) + assertEquals( + listOf( + "/v1/requests?page=0&size=500", + "/v1/requests?page=1&size=500", + "/v1/requests/$firstId", + "/v1/requests/$secondId", + ), + requestedUris.map { uri -> "${uri.path}${uri.query?.let { "?$it" } ?: ""}" } + ) + assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" }) + } + + @Test + fun `reqcells calls v1 request with cells instead of legacy by number endpoint`() { + val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf(requestWithCellsResponse(requestId)) + ) + + service.reqcells(requestId.toString()) + + assertEquals("/v1/requests/$requestId/with-cells", requestedUris.single().path) + assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/") }) + } + + @Test + fun `reqcells maps new wrapper to current request with cells dto`() { + val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val service = earthService( + responses = mutableListOf( + requestWithCellsResponse( + requestId = requestId, + requestName = "request name from service", + requestGeometry = "POLYGON ((30 10, 40 40, 20 40, 30 10))", + requestImportance = 10.0, + cellNum = 123L, + cellImportance = 8.5, + cellContour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))" + ) + ) + ) + + val result = service.reqcells(requestId.toString())!! + + assertEquals(requestId, result.request.requestId) + assertEquals("request name from service", result.request.name) + assertFalse(result.request.name == requestId.toString()) + assertEquals(10.0, result.request.importance) + assertEquals("POLYGON ((30 10, 40 40, 20 40, 30 10))", result.request.contour) + assertEquals(123L, result.cells.single().id) + assertEquals(123L, result.cells.single().num) + assertEquals(8.5, result.cells.single().importance) + assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", result.cells.single().contour) + assertEquals(0.0, result.cells.single().latitude) + assertEquals(0.0, result.cells.single().longitude) + assertFalse(result.cells.single().javaClass.declaredFields.any { field -> field.name == "name" }) + } + + @Test + fun `reqcells propagates not found response`() { + val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val service = earthService( + responses = mutableListOf( + ClientResponse.create(HttpStatus.NOT_FOUND) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body("""{"code":"REQUEST_NOT_FOUND","message":"Заявка не найдена"}""") + .build() + ) + ) + + val error = assertFailsWith { + service.reqcells(requestId.toString()) + } + + assertEquals(HttpStatus.NOT_FOUND, error.statusCode) + } + + private fun earthService( + requestedUris: MutableList = mutableListOf(), + responses: MutableList, + ): EarthService { + val builder = WebClient.builder() + .exchangeFunction { request -> + requestedUris += request.url() + Mono.just(responses.removeAt(0)) + } + return EarthService(provider(builder), url = "http://pcp-request-service") + } + + private fun cellsResponse( + body: String = """ + { + "items": [], + "page": 0, + "size": 500, + "totalItems": 0, + "totalPages": 1 + } + """.trimIndent(), + ): ClientResponse = + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build() + + private fun requestsResponse( + body: String = """ + { + "items": [], + "page": 0, + "size": 500, + "totalItems": 0, + "totalPages": 1 + } + """.trimIndent(), + ): ClientResponse = + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build() + + private fun requestDetailsResponse(body: String): ClientResponse = + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build() + + private fun requestWithCellsResponse( + requestId: UUID, + requestName: String = "request name", + requestGeometry: String = "POLYGON EMPTY", + requestImportance: Double = 1.0, + cellNum: Long = 1L, + cellImportance: Double = 1.0, + cellContour: String = "POLYGON EMPTY", + ): ClientResponse = + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body( + """ + { + "request": { + "id": "$requestId", + "name": "$requestName", + "status": "ACCEPTED", + "surveyType": "OPTICS", + "geometry": "$requestGeometry", + "importance": $requestImportance, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-02T00:00:00Z", + "kpp": [], + "highPriorityTransmit": false, + "optics": null, + "rsa": null, + "coverage": { + "requiredPercent": 100.0, + "currentPercent": 0.0 + }, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "deletedAt": null + }, + "cells": [ + { + "cellNum": $cellNum, + "name": "cell name must be ignored", + "coveragePercent": 42.5, + "importance": $cellImportance, + "contour": "$cellContour" + } + ] + } + """.trimIndent() + ) + .build() + + private fun provider(builder: WebClient.Builder): ObjectProvider = + object : ObjectProvider { + override fun getObject(vararg args: Any?): WebClient.Builder = builder + override fun getObject(): WebClient.Builder = builder + override fun getIfAvailable(): WebClient.Builder = builder + override fun getIfUnique(): WebClient.Builder = builder + override fun iterator(): MutableIterator = mutableListOf(builder).iterator() + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/GroupStateServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/GroupStateServiceTest.kt new file mode 100644 index 0000000..f9945df --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/GroupStateServiceTest.kt @@ -0,0 +1,135 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import java.time.LocalDateTime +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GroupStateServiceTest { + + private val satelliteCatalogService: SatelliteCatalogService = mock() + private val ballisticsService: BallisticsService = mock() + private val service = GroupStateService(satelliteCatalogService, ballisticsService) + + @Test + fun `group summaries calculate availability intersection`() { + doReturn(listOf(primaryGroup(), groupWithoutCommonInterval())).`when`(satelliteCatalogService).allGroups() + doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites() + doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability() + + val summaries = service.groupSummaries() + + assertEquals(2, summaries.size) + assertNotNull(summaries[0].availableInterval) + assertEquals(LocalDateTime.of(2026, 4, 24, 10, 30), summaries[0].availableInterval?.timeStart) + assertEquals(LocalDateTime.of(2026, 4, 24, 11, 30), summaries[0].availableInterval?.timeStop) + assertNull(summaries[1].availableInterval) + } + + @Test + fun `group state rejects time outside available interval`() { + doReturn(primaryGroup()).`when`(satelliteCatalogService).group(71L) + doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites() + doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability() + + val exception = assertThrows { + service.groupState(71L, LocalDateTime.of(2026, 4, 24, 10, 15)) + } + + assertTrue(exception.message!!.contains("не входит в доступный интервал")) + } + + @Test + fun `group state builds pairwise matrix from orbital points`() { + val calculationTime = LocalDateTime.of(2026, 4, 24, 10, 45) + doReturn(primaryGroup()).`when`(satelliteCatalogService).group(71L) + doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites() + doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability() + doReturn(firstOrbitalPoint(calculationTime)).`when`(ballisticsService).exactTimePoint(501L, calculationTime) + doReturn(secondOrbitalPoint(calculationTime)).`when`(ballisticsService).exactTimePoint(502L, calculationTime) + + val state = service.groupState(71L, calculationTime) + + assertEquals(71L, state.groupId) + assertEquals(calculationTime, state.calculationTime) + assertEquals(2, state.satellites.size) + assertEquals(2, state.matrix.size) + state.satellites.forEach { satellite -> + assertTrue(!satellite.omegabDeg.isNaN()) + assertTrue(!satellite.uDeg.isNaN()) + assertTrue(satellite.omegabDeg in 0.0..360.0) + assertTrue(satellite.uDeg in 0.0..360.0) + } + + val firstRow = state.matrix[0] + val secondRow = state.matrix[1] + assertEquals(0.0, firstRow.cells[0].deltaOmegabDeg) + assertEquals(0.0, secondRow.cells[1].deltaUDeg) + assertTrue(abs(firstRow.cells[1].deltaOmegabDeg + secondRow.cells[0].deltaOmegabDeg) < 0.0001) + assertTrue(abs(firstRow.cells[1].deltaUDeg + secondRow.cells[0].deltaUDeg) < 0.0001) + } + + private fun primaryGroup() = SatelliteGroupDTO( + id = 71L, + name = "KONDOR", + satelliteIds = listOf(501L, 502L) + ) + + private fun groupWithoutCommonInterval() = SatelliteGroupDTO( + id = 72L, + name = "NO-INTERSECTION", + satelliteIds = listOf(501L, 503L) + ) + + private fun satelliteSummaries() = listOf( + SatelliteSummaryDTO(id = 501L, noradId = 56756L, code = "SAT-501", name = "Sat 501"), + SatelliteSummaryDTO(id = 502L, noradId = 62138L, code = "SAT-502", name = "Sat 502"), + SatelliteSummaryDTO(id = 503L, noradId = null, code = "SAT-503", name = "Sat 503") + ) + + private fun orbitAvailability() = listOf( + SatelliteOrbitAvailabilityDTO( + satelliteId = 501L, + timeStart = LocalDateTime.of(2026, 4, 24, 10, 0), + timeStop = LocalDateTime.of(2026, 4, 24, 12, 0) + ), + SatelliteOrbitAvailabilityDTO( + satelliteId = 502L, + timeStart = LocalDateTime.of(2026, 4, 24, 10, 30), + timeStop = LocalDateTime.of(2026, 4, 24, 11, 30) + ) + ) + + private fun firstOrbitalPoint(time: LocalDateTime) = OrbPointDTO( + time = time, + revolution = 1L, + vx = 941.8995373, + vy = 1150.6919822, + vz = 7544.9920840, + x = -5357933.7872, + y = 4328646.1787, + z = 0.0 + ) + + private fun secondOrbitalPoint(time: LocalDateTime) = OrbPointDTO( + time = time, + revolution = 1499L, + vx = -272.89561674108205, + vy = -1460.1016031205972, + vz = 7539.209413654664, + x = 6782429.138044466, + y = -1205586.994919729, + z = 0.0 + ) +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmServiceTest.kt new file mode 100644 index 0000000..27d2ca3 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmServiceTest.kt @@ -0,0 +1,95 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import reactor.core.publisher.Flux +import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SatellitePdcmServiceTest { + + private val satelliteCatalogService: SatelliteCatalogService = mock() + private val ballisticsService: BallisticsService = mock() + private val service = SatellitePdcmService(satelliteCatalogService, ballisticsService) + + @Test + fun `satellite summaries mark active interval by current time`() { + val referenceTime = LocalDateTime.of(2026, 4, 24, 11, 0) + doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites() + doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability() + + val summaries = service.satelliteSummaries(referenceTime) + + assertEquals(2, summaries.size) + assertEquals(501L, summaries.first().satelliteId) + assertTrue(summaries.first().activeInterval) + assertFalse(summaries.last().activeInterval) + } + + @Test + fun `satellite details request asc-node by satellite id and not norad id`() { + val referenceTime = LocalDateTime.of(2026, 4, 24, 11, 0) + val availability = orbitAvailability().first() + doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites() + doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability() + doReturn( + Flux.just( + AscNodeDTO( + time = LocalDateTime.of(2026, 4, 24, 10, 30), + revolution = 120, + long = 37.62, + height = 512.5, + x = 1.0, + y = 2.0, + z = 3.0, + vx = 4.0, + vy = 5.0, + vz = 6.0, + keps = KeplersDTO( + ael = 7000.0, + e = 0.001, + inkl = 97.6, + omega = 123.4, + w = 45.6 + ), + localMeanTime = LocalDateTime.of(2026, 4, 24, 13, 0) + ) + ) + ).`when`(ballisticsService).ascNodes(501L, availability.timeStart, availability.timeStop) + + val details = service.satelliteDetails(501L, referenceTime) + + assertEquals(501L, details.satelliteId) + assertTrue(details.activeInterval) + assertEquals(1, details.ascNodes.size) + assertEquals(7000.0, details.ascNodes.first().keps?.ael) + assertEquals(LocalDateTime.of(2026, 4, 24, 13, 0), details.ascNodes.first().localMeanTime) + verify(ballisticsService).ascNodes(501L, availability.timeStart, availability.timeStop) + } + + private fun satelliteSummaries() = listOf( + SatelliteSummaryDTO(id = 501L, noradId = 95678L, code = "SAT-501", name = "Sat 501"), + SatelliteSummaryDTO(id = 502L, noradId = 95679L, code = "SAT-502", name = "Sat 502") + ) + + private fun orbitAvailability() = listOf( + SatelliteOrbitAvailabilityDTO( + satelliteId = 501L, + timeStart = LocalDateTime.of(2026, 4, 24, 10, 0), + timeStop = LocalDateTime.of(2026, 4, 24, 12, 0) + ), + SatelliteOrbitAvailabilityDTO( + satelliteId = 502L, + timeStart = LocalDateTime.of(2026, 4, 24, 12, 30), + timeStop = LocalDateTime.of(2026, 4, 24, 14, 0) + ) + ) +} diff --git a/services/pcp-ui-service/src/test/resources/application.yaml b/services/pcp-ui-service/src/test/resources/application.yaml new file mode 100644 index 0000000..9d2a49d --- /dev/null +++ b/services/pcp-ui-service/src/test/resources/application.yaml @@ -0,0 +1,15 @@ +spring: + application: + name: pcp-ui-service-test + cloud: + config: + enabled: false + import-check: + enabled: false + boot: + admin: + client: + enabled: false + +settings: + dynamic-plan-service: http://localhost:65535 diff --git a/services/slots-service/Dockerfile b/services/slots-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/slots-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/slots-service/build.gradle.kts b/services/slots-service/build.gradle.kts new file mode 100644 index 0000000..bfc71e2 --- /dev/null +++ b/services/slots-service/build.gradle.kts @@ -0,0 +1,113 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +version = "1.0.0" + + +kotlin { + jvmToolchain((property("versions.java") as String).toInt()) + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String)) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation(project(":libs:ballistics-lib")) + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.kafka:spring-kafka") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}") + + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.4") + + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") +// implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.postgresql:postgresql") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + implementation("org.locationtech.jts:jts-core:1.19.0") + + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + enabled = true + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/SlotsServiceApplication.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/SlotsServiceApplication.kt new file mode 100644 index 0000000..91c893c --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/SlotsServiceApplication.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.slots_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.kafka.annotation.EnableKafka +import org.springframework.scheduling.annotation.EnableScheduling + +@EnableKafka +@EnableScheduling +@SpringBootApplication +class SlotsServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/BookedSlotsTimeoutProperties.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/BookedSlotsTimeoutProperties.kt new file mode 100644 index 0000000..65ad47d --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/BookedSlotsTimeoutProperties.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.slots_service.configuration + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component +import java.time.Duration + +@Component("bookedSlotsTimeoutProperties") +@ConfigurationProperties(prefix = "pcp.slots.status.v1") +data class BookedSlotsTimeoutProperties( + var timeoutCheckInterval: Duration = Duration.ofHours(1), + var timeoutThreshold: Duration = Duration.ofHours(1) +) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..7025c99 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.slots_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(WebExchangeBindException::class) + fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { + val errors = mutableMapOf() + ex.bindingResult.fieldErrors.forEach { error -> + errors[error.field] = error.defaultMessage ?: "Validation error" + } + return Mono.just(ResponseEntity.badRequest().body(errors)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity> { + val errors = mutableMapOf() + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as FieldError).field + val errorMessage = error.defaultMessage + errors[fieldName] = errorMessage ?: "Validation error" + } + return ResponseEntity.badRequest().body(errors) + } +} \ No newline at end of file diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/KafkaConfig.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/KafkaConfig.kt new file mode 100644 index 0000000..bc73ce1 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/KafkaConfig.kt @@ -0,0 +1,96 @@ +package space.nstart.pcp.slots_service.configuration + +import org.apache.kafka.clients.admin.AdminClientConfig +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.header.Header +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaAdmin +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.listener.adapter.RecordFilterStrategy +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.nio.charset.StandardCharsets + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + companion object { + private const val HEADER_NAME = "type" + } + + @Value("\${app.kafka.topics.booked-slots}") + private lateinit var bookedSlotsTopic: String + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var bootstrapServers: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean + fun bookedSlotsTopic(): NewTopic = NewTopic(bookedSlotsTopic, 1, 1) + + @Bean + fun kafkaAdmin(): KafkaAdmin = + KafkaAdmin( + mutableMapOf( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers + ) + ) + + @Bean + fun consumerFactory(): ConsumerFactory = + DefaultKafkaConsumerFactory( + mutableMapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "latest", + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java + ) + ) + + @Bean + fun producerFactory(): ProducerFactory = + DefaultKafkaProducerFactory( + mutableMapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java + ) + ) + + @Bean + fun kafkaTemplate(): KafkaTemplate = KafkaTemplate(producerFactory()) + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + setConsumerFactory(consumerFactory()) + } + + @Bean("satelliteDeletedFilter") + fun satelliteDeletedFilter(): RecordFilterStrategy = + RecordFilterStrategy { consumerRecord: ConsumerRecord -> + val eventTypeHeader: Header? = consumerRecord.headers().lastHeader(HEADER_NAME) + if (eventTypeHeader == null) { + return@RecordFilterStrategy true + } + + val headerValue = String(eventTypeHeader.value(), StandardCharsets.UTF_8) + PcpKafkaEvent.SatelliteDeletedEvent.name != headerValue + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/ValidationConfig.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/ValidationConfig.kt new file mode 100644 index 0000000..75886da --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/ValidationConfig.kt @@ -0,0 +1,14 @@ +package space.nstart.pcp.slots_service.configuration + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ValidationConfig { + @Bean + fun validator(): Validator { + return Validation.buildDefaultValidatorFactory().validator + } +} \ No newline at end of file diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebClientConfig.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebClientConfig.kt new file mode 100644 index 0000000..8ad931b --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebClientConfig.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp.slots_service.configuration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration + +@Configuration +class WebClientConfig { + + @Bean + fun webClientBuilder(): WebClient.Builder { + val httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(230)) + + return WebClient.builder() + .clientConnector(ReactorClientHttpConnector(httpClient)) + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) + } + } +} 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 new file mode 100644 index 0000000..76efd43 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt @@ -0,0 +1,64 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotBookingRequestDTO +import java.time.LocalDateTime +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import space.nstart.pcp.slots_service.service.SlotService + + +@RestController +@RequestMapping("/api/slots/booking") +class BookingController { + private val logger = LoggerFactory.getLogger(this::class.java) + + @Autowired + private lateinit var slotService: SlotService + + @GetMapping + fun all() = slotService.allBooked() + @GetMapping("/{booked_slot_id}") + fun byId(@PathVariable("booked_slot_id") id : Long) = slotService.bookedById(id) + @GetMapping("/by-request/{request_id}") + fun byId(@PathVariable("request_id") id : String) = slotService.bookedByReqId(id) + + @PostMapping() + fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req) + @DeleteMapping + fun cancelSlot(@RequestParam("id") id : Long) = slotService.cancelSlot(id) + @PostMapping("request") + fun bookReq(@RequestBody req : BookingRequestDTO) = + slotService.bookReq(req) + @DeleteMapping("request") + fun deleteRequestBooking(@RequestParam requestId : String) = + slotService.cancelReq(requestId) + + /** + * Legacy HTTP path intentionally retired. + * Booked slots lifecycle is Kafka-first and mission-planning must publish Kafka events instead. + */ + @Deprecated("Booked slots status changes are Kafka-first; use booked slots Kafka events instead") + @PostMapping("process") + fun processBookedSlots(@RequestBody bookedSlotIds: List): Nothing { + logger.warn( + "Rejected legacy HTTP booked slots processing request for {} ids. Kafka lifecycle is the only supported primary path.", + bookedSlotIds.distinct().size + ) + throw ResponseStatusException( + HttpStatus.GONE, + "Legacy booked slots HTTP processing is retired. Publish booked slots status changes via Kafka." + ) + } + +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/MissionController.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/MissionController.kt new file mode 100644 index 0000000..40a57d6 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/MissionController.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.slots_service.controller + +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SatelliteMissionBatchRequestDTO +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/slots/mission") +class MissionController { + + @Autowired + private lateinit var slotService: SlotService + + @GetMapping("/satellite") + fun bySatelliteAndInterval( + @RequestParam("satellite_id") satelliteId: Long, + @RequestParam timeStart: LocalDateTime, + @RequestParam timeStop: LocalDateTime + ) = slotService.bookedBySatelliteAndInterval(satelliteId, timeStart, timeStop) + + @PostMapping("/satellites") + fun bySatellitesAndInterval(@RequestBody body: SatelliteMissionBatchRequestDTO) = + slotService.bookedBySatellitesAndInterval(body.satelliteIds, body.timeStart, body.timeStop) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SatelliteController.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SatelliteController.kt new file mode 100644 index 0000000..b4c6c83 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SatelliteController.kt @@ -0,0 +1,47 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + + +@RestController +@RequestMapping("/api/satellite") +class SatelliteController { + + @Autowired + private lateinit var slotService: SlotService + + @PostMapping("/rva/{id}/{duration}") + fun rva(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : StationDTO) + = slotService.rva(id, duration, station) + + + @PostMapping("/rva-common/{id}/{duration}") + fun rvaCommon(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : List) + = slotService.rvaCommon(id, duration, station) + + @PostMapping("/rva-merge/{id}/{duration}") + fun rvaMerge(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : List) + = slotService.rvaMerge(id, duration, station) + + @PostMapping("/rva-merge-on-rev/{id}/{duration}") + fun rvaMergeOnRev(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : List) + = slotService.rvaMergeOnRev(id, duration, station) + + + @PostMapping("/{id}/send-ic") + fun publishInitialConditions(@PathVariable id: Long, @RequestParam time : LocalDateTime): ResponseEntity { + slotService.publishInitialConditions(id, time, true) + return ResponseEntity.accepted().build() + } + +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SatelliteIcController.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SatelliteIcController.kt new file mode 100644 index 0000000..da3f039 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SatelliteIcController.kt @@ -0,0 +1,44 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +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.InitialConditionsDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.slots_service.service.SatelliteIcService + +@RestController +@RequestMapping("/api/satellite-ic") +class SatelliteIcController( + private val satelliteIcService: SatelliteIcService +) { + + @GetMapping + fun all() = satelliteIcService.all() + + @GetMapping("/{satellite_id}") + fun bySatelliteId(@PathVariable("satellite_id") satelliteId: Long) = + satelliteIcService.bySatelliteId(satelliteId) + + @PostMapping + fun create(@RequestBody request: SatelliteICDTO) = + satelliteIcService.create(request) + + @PutMapping("/{satellite_id}") + fun update( + @PathVariable("satellite_id") satelliteId: Long, + @RequestBody request: InitialConditionsDTO + ) = satelliteIcService.update(satelliteId, request) + + @DeleteMapping("/{satellite_id}") + fun delete(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity { + satelliteIcService.delete(satelliteId) + return ResponseEntity.noContent().build() + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SlotController.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SlotController.kt new file mode 100644 index 0000000..30627a7 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/SlotController.kt @@ -0,0 +1,128 @@ +package space.nstart.pcp.slots_service.controller + +import jakarta.validation.Valid +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.apache.kafka.common.protocol.types.Field +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + + +@RestController +@RequestMapping("api/slots") +class SlotController { + + @Autowired + private lateinit var slotService: SlotService + + + @Operation(summary = "Формирование слотов замкнутых орбит") + @PostMapping("test-init") + fun init(@RequestParam sats : List, + @RequestParam slotDuration: Long, + @RequestParam dailyDuration : Long, + @RequestParam revolutionDuration : Long, + @RequestParam recover : Boolean? + ) = slotService.init(sats, slotDuration, dailyDuration, revolutionDuration, recover) + + + @Operation(summary = "Расчет покрытие площади") + @GetMapping("poly-cover") + fun cover( + @Parameter(description = "Контур полигона", example = "POLYGON ((10 15, 12 17, 14 17, 16 15, 14 13, 12 13, 10 15))") + @RequestParam wkt : String, + @Parameter(description = "Время начала", example = "2026-02-05T00:00:00.000") + @RequestParam timeStart : LocalDateTime, + @Parameter(description = "Время конца", example = "2026-02-06T00:00:00.000") + @RequestParam timeStop : LocalDateTime, + @Parameter(description = "Признак ветви") + @RequestParam("revSign") sign : RevolutionSign?, + @Parameter(description = "Признак расчета покрытия") + @RequestParam("cov") cov : Boolean?, + @Parameter(description = "Список КА") + @RequestParam("satellites") satellites : List?, + @Parameter(description = "Угол Солнца, град") + @RequestParam("sun") sun : Double?, + @Parameter(description = "Вариант расчета покрытия") + @RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?, + ) + = slotService.polySlots(wkt, timeStart, timeStop, sign, cov, null, satellites, sun, coverageStrategy) + + @PostMapping("poly-cover/batch") + fun coverBatch(@Valid @RequestBody body: SlotCoverageBatchRequestDTO) = + slotService.polySlotsBatch(body) + + + @GetMapping("request-cover") + @Operation(summary = "Расчет покрытие заявки") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Слоты покрытия заявки", + content = [ + Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = SlotDTO::class)) + ) + ] + ) + ] + ) + fun coverReq( + @Parameter(description = "Id") + @RequestParam requestId : String, + @Parameter(description = "Время начала", example = "2026-02-05T00:00:00.000") + @RequestParam timeStart : LocalDateTime, + @Parameter(description = "Время конца", example = "2026-02-06T00:00:00.000") + @RequestParam timeStop : LocalDateTime, + @Parameter(description = "Признак ветви") + @RequestParam("revSign") sign : RevolutionSign?, + @Parameter(description = "Признак расчета покрытия") + @RequestParam("cov") cov : Boolean?, + @Parameter(description = "Список КА") + @RequestParam("satellites") satellites : List?, + @Parameter(description = "Угол Солнца, град") + @RequestParam("sun") sun : Double?, + @Parameter(description = "Вариант расчета покрытия") + @RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?, + ) : List + = slotService.reqCover(requestId, timeStart, timeStop, sign, cov, satellites, sun, coverageStrategy).toList() + + @GetMapping("interval") + @Operation(summary = "Получение всех слотов на заданном интервале времени") + fun allByInterval( + @Parameter(description = "Идентификатор спутника") + @RequestParam satelliteId: Long, + @Parameter(description = "Время начала", example = "2026-02-05T00:00:00.000") + @RequestParam timeStart: LocalDateTime, + @Parameter(description = "Время конца", example = "2026-02-06T00:00:00.000") + @RequestParam timeStop: LocalDateTime, + ): List = slotService.allByInterval(satelliteId, timeStart, timeStop) + + @GetMapping("calculation-summary") + @Operation(summary = "Получение сводки рассчитанных слотов по спутникам") + fun calculationSummary( + @Parameter(description = "Список идентификаторов спутников") + @RequestParam satelliteIds: List + ) = slotService.slotCalculationSummaries(satelliteIds) + + +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedRequestEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedRequestEntity.kt new file mode 100644 index 0000000..24c8187 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedRequestEntity.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.slots_service.entity + +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Column +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + + +@Entity +@Table(name = "booked_slot_request") +class BookedRequestEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val bookedSlotRequestId : Long? = null, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "booked_slot_id", nullable = false) + val bookedSlot : BookedSlotEntity = BookedSlotEntity(), + @Column(name = "request_id", nullable = false, length = 50) + var requestId : String = "", +) { +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedRequestsRepository.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedRequestsRepository.kt new file mode 100644 index 0000000..0a9d502 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedRequestsRepository.kt @@ -0,0 +1,8 @@ +package space.nstart.pcp.slots_service.entity + +import org.springframework.data.jpa.repository.JpaRepository + +interface BookedRequestsRepository : JpaRepository { + + fun findByBookedSlot_BookedSlotIdAndRequestId(slot : Long, req : String) : BookedRequestEntity? +} \ No newline at end of file diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt new file mode 100644 index 0000000..e81dcb8 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt @@ -0,0 +1,54 @@ +package space.nstart.pcp.slots_service.entity + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import org.hibernate.annotations.Fetch +import org.hibernate.annotations.FetchMode +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus + + +@Entity +@Table(name = "booked_slot") +class BookedSlotEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val bookedSlotId : Long? = null, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "slot_id", nullable = false) + val slot : SlotEntity = SlotEntity(), + @Column(nullable = false) + var cycle : Long = 0, + @OneToMany( + fetch = FetchType.LAZY, + mappedBy = "bookedSlot", + cascade = [CascadeType.PERSIST, CascadeType.MERGE], + orphanRemoval = true + ) + @Fetch(FetchMode.SUBSELECT) + val requests: MutableList? = null, + @Column(name = "status", columnDefinition = "TEXT", nullable = false) + var status : String = BookedSlotStatus.BOOKED.toString() +) { + constructor(slot : SlotEntity, cycle : Long) : this( + null, + slot, + cycle + ) + + fun toDTO() = BookedSlotDTO( + bookedSlotId = bookedSlotId ?: 0, + satelliteId = slot.satelliteId, + slotNum = slot.slotNum, + cycle = cycle + ) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt new file mode 100644 index 0000000..0eab57e --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.slots_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "satellite_ic") +class SatelliteIcEntity( + @Id + @Column(name = "satellite_id", nullable = false) + val satelliteId: Long = 0, + @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 +) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt new file mode 100644 index 0000000..757b134 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt @@ -0,0 +1,121 @@ +package space.nstart.pcp.slots_service.entity + +import jakarta.persistence.* +import org.hibernate.annotations.Fetch +import org.hibernate.annotations.FetchMode +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +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.pcp_types_lib.utils.wkt.WKTParser +import space.nstart.pcp.slots_service.model.Mar +import java.time.LocalDateTime + + +@Entity +@Table(name = "slot") +class SlotEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val slotId : Long? = null, + @Column(nullable = false) + val slotNum : Long = 0, + @Column(nullable = false) + var cycle : Long = 0, + @Column(nullable = false) + val satelliteId : Long = 0, + @Column(nullable = false) + val coveringType : Int = 0, + @Column(nullable = false) + var tn : LocalDateTime = LocalDateTime.now(), + @Column(nullable = false) + var tk : LocalDateTime = LocalDateTime.now(), + val roll : Double = 0.0, + @Column(name = "contour_wkt", columnDefinition = "TEXT", nullable = false) + val contour : String = "", + @Column(nullable = false) + var revolution : Long = 0, + @Column(name = "revolution_sign", columnDefinition = "VARCHAR", nullable = false) + val revolutionSign: String = RevolutionSign.ASC.toString(), + @OneToMany( + fetch = FetchType.LAZY, + mappedBy = "slot", + cascade = [CascadeType.PERSIST, CascadeType.MERGE], + orphanRemoval = true + ) + @Fetch(FetchMode.SUBSELECT) + val modes: MutableList? = null, + + val latitude : Double = 0.0, + val longitude : Double = 0.0, +) { + + constructor(mar : Mar, slotNum : Long, type : Int) : this( + slotNum = slotNum, + cycle = 1, + satelliteId = mar.sat, + tn = mar.tn, + tk = mar.tk, + roll = mar.roll, + contour = mar.contour, + revolution = mar.vit, + revolutionSign = mar.revolutionSign.toString(), + coveringType = type + ) + + fun toMar(parser : WKTParser) = Mar( + sat = satelliteId, + tn = tn, + tk = tk, + roll = roll, + contour = contour, + poly = parser.parseWKT(contour), + vit = revolution, + revolutionSign = RevolutionSign.valueOf(revolutionSign), + slotNum = slotNum + ) + + fun toDTO() = SlotDTO( + cycle, + satelliteId, + tn, + tk, + roll, + contour, + revolution, + RevolutionSign.valueOf(revolutionSign), + slotNum, + SlotStatus.AVAILABLE, + latitude, + longitude + ) + + fun toDTO(status : SlotStatus) = SlotDTO( + cycle, + satelliteId, + tn, + tk, + roll, + contour, + revolution, + RevolutionSign.valueOf(revolutionSign), + slotNum, + state = status, + latitude, + longitude + ) + + fun toDTO(booked : Boolean, band : Boolean) = SlotDTO( + cycle, + satelliteId, + tn, + tk, + roll, + contour, + revolution, + RevolutionSign.valueOf(revolutionSign), + slotNum, + state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE, + latitude, + longitude + ) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/BookedSlot.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/BookedSlot.kt new file mode 100644 index 0000000..9778622 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/BookedSlot.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp.slots_service.model + +import java.time.LocalDateTime + +class BookedSlot( + val t : LocalDateTime, + val tEnd : LocalDateTime, + val roll : Double, + var neighbours : Short, + val cycle : Long +) \ No newline at end of file diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolver.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolver.kt new file mode 100644 index 0000000..ee14591 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolver.kt @@ -0,0 +1,927 @@ +package space.nstart.pcp.slots_service.model + +import org.locationtech.jts.geom.Envelope +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.index.strtree.STRtree +import org.slf4j.Logger +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 java.time.LocalDateTime +import kotlin.math.abs +import kotlin.math.max + +class ContinuousReqCoverSolver( + private val logger: Logger, + private val satellitesById: Map +) { + + private data class ContinuousCandidate( + val base: CoverageCandidate, + val leftLongitude: Double, + val rightLongitude: Double, + val centerLongitude: Double, + val trackLongitude: Double + ) + + private data class TransitionEvaluation( + val candidate: ContinuousCandidate, + val gainArea: Double, + val overlapArea: Double, + val geometryGap: Double, + val longitudeAdvance: Double, + val branchPenalty: Int, + val rollDelta: Double, + val connected: Boolean, + val meaningfulGain: Boolean, + val score: Double + ) + + private data class StartEvaluation( + val candidate: ContinuousCandidate, + val gainArea: Double, + val previewGainArea: Double, + val previewLength: Int, + val rightReach: Double, + val score: Double + ) + + private data class StartPreScore( + val candidate: ContinuousCandidate, + val gainArea: Double, + val score: Double + ) + + private data class PreviewSummary( + val gainArea: Double, + val length: Int, + val rightReach: Double + ) + + private data class LongitudeOrdering( + val useSlotLongitude: Boolean + ) + + private data class PairwiseTransitionData( + val relation: LongitudeRelation, + val longitudeAdvance: Double, + val branchPenalty: Int, + val rollDelta: Double, + val chainProgressBonus: Double, + val connected: Boolean, + val geometryGap: Double + ) + + private data class CandidateSearchContext( + val groupsByKey: Map, + val candidateIndexes: CoverageCandidateIndexes, + val longitudeOrdering: LongitudeOrdering, + val orderingLongitudeById: DoubleArray, + val sortedByLongitudeIds: IntArray, + val sortedLongitudePositionsById: IntArray, + val groupForwardNeighborIds: Array, + val spatialNeighborIds: Array, + val pairwiseCache: MutableMap + ) + + private class LocalActiveOverlay( + private val baseActive: BooleanArray + ) { + private val locallyDisabled = hashSetOf() + + fun isActive(candidateId: Int): Boolean = baseActive[candidateId] && candidateId !in locallyDisabled + + fun deactivate(candidateId: Int) { + locallyDisabled += candidateId + } + } + + private class SegmentState { + private val selected = mutableListOf() + private val selectedBySatellite = mutableMapOf>() + private val maxGroupIndexByKey = mutableMapOf() + private val groupBounds = mutableMapOf>() + + fun add(candidate: ContinuousCandidate) { + selected += candidate + selectedBySatellite.getOrPut(candidate.base.slot.satelliteId) { mutableListOf() }.add(candidate) + maxGroupIndexByKey.merge(candidate.base.groupKey, candidate.base.groupIndex, ::maxOf) + val currentBounds = groupBounds[candidate.base.groupKey] + groupBounds[candidate.base.groupKey] = if (currentBounds == null) { + candidate.base.slot.tn to candidate.base.slot.tk + } else { + minOf(currentBounds.first, candidate.base.slot.tn) to maxOf(currentBounds.second, candidate.base.slot.tk) + } + } + + fun candidates(): List = selected + + fun size(): Int = selected.size + + fun selectedOnSatellite(satelliteId: Long): List = + selectedBySatellite[satelliteId] ?: emptyList() + + fun maxGroupIndex(groupKey: CoverageGroupKey): Int? = maxGroupIndexByKey[groupKey] + + fun projectedGroupDurationSeconds(groupKey: CoverageGroupKey, candidate: ContinuousCandidate): Long { + val currentBounds = groupBounds[groupKey] + if (currentBounds == null) { + return Duration.between(candidate.base.slot.tn, candidate.base.slot.tk).seconds.coerceAtLeast(0L) + } + val start = minOf(currentBounds.first, candidate.base.slot.tn) + val end = maxOf(currentBounds.second, candidate.base.slot.tk) + return Duration.between(start, end).seconds.coerceAtLeast(0L) + } + } + + private val support = CoverageSolverSupport(logger, satellitesById) + + fun select(targetWkt: String, slots: List): List { + if (slots.isEmpty()) { + return emptyList() + } + + val slotChains = mergeSlotsToChains(slots) + logger.info("Continuous solver chains after aggregation: {}", slotChains.size) + if (slotChains.isEmpty()) { + return emptyList() + } + + val targetGeometry = support.parseTargetGeometry(targetWkt) + if (targetGeometry.isEmpty || targetGeometry.area <= CoverageSolverSupport.AREA_EPS) { + return emptyList() + } + + val preparedCandidates = support.buildCandidates(slotChains, targetGeometry) + val baseCandidates = preparedCandidates.first + if (baseCandidates.isEmpty()) { + return emptyList() + } + val chainCandidates = baseCandidates.map { it.toContinuousCandidate(targetGeometry) } + val searchContext = buildSearchContext( + candidates = chainCandidates, + groupsByKey = preparedCandidates.second + ) + + val active = BooleanArray(chainCandidates.size) { true } + val selected = mutableListOf() + var uncovered = targetGeometry + var uncoveredEnvelope = targetGeometry.envelopeInternal + + val bookedSelected = mutableListOf() + chainCandidates.forEach { candidate -> + when (candidate.base.slot.state) { + SlotStatus.BOOKED -> { + bookedSelected += candidate.base + selected += candidate.base + uncovered = support.safeDifference(uncovered, candidate.base.coveredGeometry) + uncoveredEnvelope = uncovered.envelopeInternal + active[candidate.base.id] = false + } + SlotStatus.AVAILABLE -> Unit + else -> active[candidate.base.id] = false + } + } + if (bookedSelected.isNotEmpty()) { + support.deactivateConflicts( + bookedSelected, + searchContext.candidateIndexes.satelliteCandidates, + chainCandidates.map { it.base }, + active + ) + } + + var current = chooseStart(chainCandidates, active, targetGeometry, searchContext) + while (current != null) { + val segment = buildContinuousSegment( + anchor = current, + candidates = chainCandidates, + active = active, + uncovered = uncovered, + targetGeometry = targetGeometry, + searchContext = searchContext + ) + if (segment.isEmpty()) { + active[current.base.id] = false + current = chooseStart(chainCandidates, active, targetGeometry, searchContext) + continue + } + + val accepted = mutableListOf() + for (candidate in segment) { + if (!active[candidate.base.id]) { + continue + } + if (!candidate.base.coveredEnvelope.intersects(uncoveredEnvelope)) { + active[candidate.base.id] = false + continue + } + + val gainArea = support.safeIntersection(candidate.base.coveredGeometry, uncovered).area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + active[candidate.base.id] = false + continue + } + + selected += candidate.base + accepted += candidate.base + uncovered = support.safeDifference(uncovered, candidate.base.coveredGeometry) + uncoveredEnvelope = uncovered.envelopeInternal + active[candidate.base.id] = false + + if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) { + break + } + } + + if (accepted.isNotEmpty()) { + support.deactivateConflicts( + accepted, + searchContext.candidateIndexes.satelliteCandidates, + chainCandidates.map { it.base }, + active + ) + } + + if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) { + break + } + + current = chooseStart(chainCandidates, active, targetGeometry, searchContext) + } + + val reducedSelected = support.removeRedundant(selected) + val preparedSourceCoverageMap = support.buildPreparedSourceCoverageMap(reducedSelected) + val resultSlots = support.collectUsefulSlotsFromSelectedChains(reducedSelected, targetGeometry) + val filteredResult = support.removeNonCoveringAndFullyCoveredResultSlots( + slots = resultSlots, + targetGeometry = targetGeometry, + preparedSourceCoverages = preparedSourceCoverageMap + ) + logger.info( + "Continuous coverage solver result: chains={}, selectedChains={}, resultSlots={}", + chainCandidates.size, + selected.size, + filteredResult.size + ) + return filteredResult.sortedWith(compareBy( + { it.tn }, + { it.satelliteId }, + { it.cycle }, + { it.slotNumber } + )) + } + + private fun chooseStart( + candidates: List, + active: BooleanArray, + targetGeometry: Geometry, + searchContext: CandidateSearchContext + ): ContinuousCandidate? { + val available = candidates + .asSequence() + .filter { active[it.base.id] && it.base.slot.state == SlotStatus.AVAILABLE } + .mapNotNull { candidate -> + val gainArea = candidate.base.coveredGeometry.area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + return@mapNotNull null + } + StartPreScore( + candidate = candidate, + gainArea = gainArea, + score = cheapStartScore(candidate, gainArea, searchContext) + ) + } + .sortedWith( + compareByDescending { it.score } + .thenByDescending { it.gainArea } + .thenByDescending { orderingLongitude(it.candidate, searchContext.longitudeOrdering) } + .thenByDescending { it.candidate.rightLongitude } + ) + .toList() + if (available.isEmpty()) { + return null + } + + val exactEvaluations = available + .take(START_PREVIEW_TOP_K) + .map { start -> + val preview = previewSegment( + anchor = start.candidate, + candidates = candidates, + active = active, + targetGeometry = targetGeometry, + searchContext = searchContext + ) + val score = preview.gainArea * START_PREVIEW_GAIN_WEIGHT + + start.gainArea * START_GAIN_WEIGHT + + preview.length * START_PREVIEW_LENGTH_WEIGHT + + max(0.0, preview.rightReach - orderingLongitude(start.candidate, searchContext.longitudeOrdering)) * + START_RIGHT_REACH_WEIGHT + StartEvaluation( + candidate = start.candidate, + gainArea = start.gainArea, + previewGainArea = preview.gainArea, + previewLength = preview.length, + rightReach = preview.rightReach, + score = score + ) + } + + return exactEvaluations.maxWithOrNull( + compareBy { it.score } + .thenBy { it.previewGainArea } + .thenBy { it.previewLength } + .thenBy { it.gainArea } + .thenBy { it.rightReach } + .thenByDescending { orderingLongitude(it.candidate, searchContext.longitudeOrdering) } + .thenByDescending { it.candidate.rightLongitude } + .thenByDescending { it.candidate.base.slot.tn } + .thenByDescending { it.candidate.base.slot.slotNumber } + )?.candidate + } + + private fun chooseNext( + previous: ContinuousCandidate, + candidates: List, + active: LocalActiveOverlay, + uncovered: Geometry, + uncoveredEnvelope: Envelope, + targetGeometry: Geometry, + segmentState: SegmentState, + searchContext: CandidateSearchContext + ): ContinuousCandidate? { + val evaluations = evaluateCandidates( + previous = previous, + candidates = candidates, + candidateIds = neighborCandidateIds(previous, searchContext, LONGITUDE_NEIGHBOR_LIMIT), + active = active, + uncovered = uncovered, + uncoveredEnvelope = uncoveredEnvelope, + targetGeometry = targetGeometry, + segmentState = segmentState, + searchContext = searchContext + ) + val boundedEvaluations = if (evaluations.isNotEmpty()) { + evaluations + } else { + evaluateCandidates( + previous = previous, + candidates = candidates, + candidateIds = neighborCandidateIds(previous, searchContext, EXTENDED_LONGITUDE_NEIGHBOR_LIMIT), + active = active, + uncovered = uncovered, + uncoveredEnvelope = uncoveredEnvelope, + targetGeometry = targetGeometry, + segmentState = segmentState, + searchContext = searchContext + ) + } + if (boundedEvaluations.isEmpty()) { + return null + } + + val connectedMeaningful = boundedEvaluations.filter { it.connected && it.meaningfulGain } + if (connectedMeaningful.isNotEmpty()) { + return connectedMeaningful.maxWithOrNull(connectedComparator())?.candidate + } + + return boundedEvaluations.maxWithOrNull(fallbackComparator())?.candidate + } + + private fun evaluateCandidates( + previous: ContinuousCandidate, + candidates: List, + candidateIds: IntArray, + active: LocalActiveOverlay, + uncovered: Geometry, + uncoveredEnvelope: Envelope, + targetGeometry: Geometry, + segmentState: SegmentState, + searchContext: CandidateSearchContext + ): List { + if (candidateIds.isEmpty()) { + return emptyList() + } + return candidateIds.asSequence() + .map { candidates[it] } + .filter { active.isActive(it.base.id) && it.base.slot.state == SlotStatus.AVAILABLE && it.base.id != previous.base.id } + .filter { candidate -> canFollowInSegment(previous, candidate, segmentState) } + .mapNotNull { candidate -> + evaluateTransition( + previous = previous, + candidate = candidate, + uncovered = uncovered, + uncoveredEnvelope = uncoveredEnvelope, + targetGeometry = targetGeometry, + searchContext = searchContext + ) + } + .toList() + } + + private fun buildContinuousSegment( + anchor: ContinuousCandidate, + candidates: List, + active: BooleanArray, + uncovered: Geometry, + targetGeometry: Geometry, + searchContext: CandidateSearchContext + ): List { + val segmentState = SegmentState() + val localActive = LocalActiveOverlay(active) + var localUncovered = uncovered + var localUncoveredEnvelope = uncovered.envelopeInternal + var current: ContinuousCandidate? = anchor + + while (current != null && localActive.isActive(current.base.id)) { + if (!current.base.coveredEnvelope.intersects(localUncoveredEnvelope)) { + localActive.deactivate(current.base.id) + break + } + + val gainArea = support.safeIntersection(current.base.coveredGeometry, localUncovered).area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + localActive.deactivate(current.base.id) + break + } + + segmentState.add(current) + localActive.deactivate(current.base.id) + localUncovered = support.safeDifference(localUncovered, current.base.coveredGeometry) + localUncoveredEnvelope = localUncovered.envelopeInternal + if (localUncovered.isEmpty || localUncovered.area <= CoverageSolverSupport.AREA_EPS) { + break + } + + current = chooseNext( + previous = current, + candidates = candidates, + active = localActive, + uncovered = localUncovered, + uncoveredEnvelope = localUncoveredEnvelope, + targetGeometry = targetGeometry, + segmentState = segmentState, + searchContext = searchContext + ) + } + + return segmentState.candidates() + } + + private fun previewSegment( + anchor: ContinuousCandidate, + candidates: List, + active: BooleanArray, + targetGeometry: Geometry, + searchContext: CandidateSearchContext + ): PreviewSummary { + val previewState = SegmentState() + val localActive = LocalActiveOverlay(active) + var localUncovered = targetGeometry + var localUncoveredEnvelope = targetGeometry.envelopeInternal + var current: ContinuousCandidate? = anchor + var totalGain = 0.0 + var depth = 0 + var rightReach = orderingLongitude(anchor, searchContext.longitudeOrdering) + + while (current != null && localActive.isActive(current.base.id) && depth < START_LOOKAHEAD_STEPS) { + if (!current.base.coveredEnvelope.intersects(localUncoveredEnvelope)) { + break + } + + val gainArea = support.safeIntersection(current.base.coveredGeometry, localUncovered).area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + break + } + + totalGain += gainArea + rightReach = max(rightReach, orderingLongitude(current, searchContext.longitudeOrdering)) + previewState.add(current) + localActive.deactivate(current.base.id) + localUncovered = support.safeDifference(localUncovered, current.base.coveredGeometry) + localUncoveredEnvelope = localUncovered.envelopeInternal + if (localUncovered.isEmpty || localUncovered.area <= CoverageSolverSupport.AREA_EPS) { + break + } + + current = chooseNext( + previous = current, + candidates = candidates, + active = localActive, + uncovered = localUncovered, + uncoveredEnvelope = localUncoveredEnvelope, + targetGeometry = targetGeometry, + segmentState = previewState, + searchContext = searchContext + ) + depth++ + } + + return PreviewSummary( + gainArea = totalGain, + length = previewState.size(), + rightReach = rightReach + ) + } + + private fun evaluateTransition( + previous: ContinuousCandidate, + candidate: ContinuousCandidate, + uncovered: Geometry, + uncoveredEnvelope: Envelope, + targetGeometry: Geometry, + searchContext: CandidateSearchContext + ): TransitionEvaluation? { + if (!candidate.base.coveredEnvelope.intersects(uncoveredEnvelope)) { + return null + } + + val pairwise = pairwiseTransition(previous, candidate, searchContext) + if (pairwise.relation == LongitudeRelation.BACKWARD) { + return null + } + if (pairwise.relation == LongitudeRelation.SAME && !pairwise.connected) { + return null + } + + val gainArea = support.safeIntersection(candidate.base.coveredGeometry, uncovered).area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + return null + } + + val meaningfulGain = gainArea >= meaningfulGainThreshold(targetGeometry.area) + if (pairwise.relation == LongitudeRelation.SAME && !meaningfulGain) { + return null + } + + val overlapArea = candidate.base.coveredGeometry.area - gainArea + val score = gainArea * TRANSITION_GAIN_WEIGHT - + overlapArea * TRANSITION_OVERLAP_WEIGHT - + pairwise.branchPenalty * TRANSITION_BRANCH_WEIGHT - + pairwise.rollDelta * TRANSITION_ROLL_WEIGHT - + pairwise.geometryGap * TRANSITION_GAP_WEIGHT + + max(0.0, pairwise.longitudeAdvance) * TRANSITION_RIGHT_PROGRESS_WEIGHT + + pairwise.chainProgressBonus * TRANSITION_CHAIN_PROGRESS_WEIGHT + + if (pairwise.connected) TRANSITION_CONTINUITY_BONUS else 0.0 + + return TransitionEvaluation( + candidate = candidate, + gainArea = gainArea, + overlapArea = overlapArea, + geometryGap = pairwise.geometryGap, + longitudeAdvance = max(0.0, pairwise.longitudeAdvance), + branchPenalty = pairwise.branchPenalty, + rollDelta = pairwise.rollDelta, + connected = pairwise.connected, + meaningfulGain = meaningfulGain, + score = score + ) + } + + private fun pairwiseTransition( + previous: ContinuousCandidate, + candidate: ContinuousCandidate, + searchContext: CandidateSearchContext + ): PairwiseTransitionData { + val key = pairwiseKey(previous.base.id, candidate.base.id) + return searchContext.pairwiseCache.getOrPut(key) { + val relation = longitudeRelation(previous, candidate, searchContext.longitudeOrdering) + val longitudeAdvance = orderingLongitude(candidate, searchContext.longitudeOrdering) - + orderingLongitude(previous, searchContext.longitudeOrdering) + val branchPenalty = branchPenalty(previous, candidate) + val rollDelta = abs(previous.base.slot.roll - candidate.base.slot.roll) + val chainProgressBonus = chainProgressBonus(previous, candidate) + + if (relation == LongitudeRelation.BACKWARD) { + return@getOrPut PairwiseTransitionData( + relation = relation, + longitudeAdvance = longitudeAdvance, + branchPenalty = branchPenalty, + rollDelta = rollDelta, + chainProgressBonus = chainProgressBonus, + connected = false, + geometryGap = Double.MAX_VALUE + ) + } + + val connected = runCatching { + previous.base.coveredGeometry.intersects(candidate.base.coveredGeometry) || + previous.base.coveredGeometry.touches(candidate.base.coveredGeometry) + }.getOrDefault(false) + val geometryGap = if (connected) { + 0.0 + } else { + runCatching { previous.base.coveredGeometry.distance(candidate.base.coveredGeometry) } + .getOrDefault(envelopeGap(previous.base.coveredEnvelope, candidate.base.coveredEnvelope)) + } + + PairwiseTransitionData( + relation = relation, + longitudeAdvance = longitudeAdvance, + branchPenalty = branchPenalty, + rollDelta = rollDelta, + chainProgressBonus = chainProgressBonus, + connected = connected, + geometryGap = geometryGap + ) + } + } + + private fun connectedComparator(): Comparator = + compareBy { it.score } + .thenBy { it.gainArea } + .thenBy { -it.overlapArea } + .thenBy { -it.longitudeAdvance } + .thenBy { -it.branchPenalty } + .thenBy { -it.rollDelta } + .thenBy { -it.candidate.base.slot.tn.toLocalTime().toSecondOfDay() } + + private fun fallbackComparator(): Comparator = + compareBy { it.gainArea } + .thenBy { -it.score } + .thenBy { -it.longitudeAdvance } + .thenBy { -it.branchPenalty } + .thenBy { -it.rollDelta } + .thenBy { -it.geometryGap } + .thenBy { -it.overlapArea } + + private fun cheapStartScore( + candidate: ContinuousCandidate, + gainArea: Double, + searchContext: CandidateSearchContext + ): Double { + val continuationCount = searchContext.groupForwardNeighborIds[candidate.base.id].size + val spatialCount = searchContext.spatialNeighborIds[candidate.base.id].count { neighborId -> + orderingLongitudeById(neighborId, searchContext) >= orderingLongitude(candidate, searchContext.longitudeOrdering) + } + val rightReach = cheapRightReach(candidate, searchContext) + return gainArea * START_GAIN_WEIGHT + + continuationCount * START_CHEAP_GROUP_WEIGHT + + spatialCount * START_CHEAP_SPATIAL_WEIGHT + + max(0.0, rightReach - orderingLongitude(candidate, searchContext.longitudeOrdering)) * START_RIGHT_REACH_WEIGHT + } + + private fun cheapRightReach( + candidate: ContinuousCandidate, + searchContext: CandidateSearchContext + ): Double { + var rightReach = orderingLongitude(candidate, searchContext.longitudeOrdering) + val position = searchContext.sortedLongitudePositionsById[candidate.base.id] + val until = minOf(searchContext.sortedByLongitudeIds.size, position + START_CHEAP_RIGHT_REACH_WINDOW + 1) + for (index in position + 1 until until) { + rightReach = max(rightReach, orderingLongitudeById(searchContext.sortedByLongitudeIds[index], searchContext)) + } + val group = searchContext.groupsByKey[candidate.base.groupKey] + if (group != null && candidate.base.groupIndex < group.candidateIds.lastIndex) { + val lastId = group.candidateIds.last() + rightReach = max(rightReach, orderingLongitudeById(lastId, searchContext)) + } + return rightReach + } + + private fun neighborCandidateIds( + previous: ContinuousCandidate, + searchContext: CandidateSearchContext, + longitudeWindow: Int + ): IntArray { + val ids = linkedSetOf() + ids += searchContext.groupForwardNeighborIds[previous.base.id].asIterable() + ids += searchContext.spatialNeighborIds[previous.base.id].asIterable() + + val position = searchContext.sortedLongitudePositionsById[previous.base.id] + val until = minOf(searchContext.sortedByLongitudeIds.size, position + longitudeWindow + 1) + for (index in position + 1 until until) { + ids += searchContext.sortedByLongitudeIds[index] + } + + return ids.toIntArray() + } + + private fun branchPenalty(previous: ContinuousCandidate, candidate: ContinuousCandidate): Int = + if (previous.base.slot.revolutionSign == candidate.base.slot.revolutionSign) 0 else 1 + + private fun chainProgressBonus(previous: ContinuousCandidate, candidate: ContinuousCandidate): Double = + when { + candidate.base.groupKey != previous.base.groupKey -> 0.0 + candidate.base.groupIndex <= previous.base.groupIndex -> 0.0 + else -> 1.0 / (candidate.base.groupIndex - previous.base.groupIndex) + } + + private fun meaningfulGainThreshold(targetArea: Double): Double = + max(CoverageSolverSupport.AREA_EPS * 10.0, targetArea * MEANINGFUL_GAIN_RATIO) + + private fun mergeSlotsToChains(slots: List): List = + support.mergeSlotsToChains(slots) + + private fun canFollowInSegment( + previous: ContinuousCandidate, + candidate: ContinuousCandidate, + segmentState: SegmentState + ): Boolean { + val candidateBase = candidate.base + val sameGroupContinuation = candidateBase.groupKey == previous.base.groupKey && + candidateBase.groupIndex > previous.base.groupIndex && + candidateBase.slot.revolutionSign == previous.base.slot.revolutionSign && + abs(candidateBase.slot.roll - previous.base.slot.roll) < CoverageSolverSupport.ROLL_EPS + + if (!sameGroupContinuation && segmentState.selectedOnSatellite(candidateBase.slot.satelliteId).any { selected -> + support.intervalsIntersect( + candidateBase.slot.tn, + candidateBase.slot.tk, + selected.base.blockedStart, + selected.base.blockedEnd + ) + } + ) { + return false + } + + val maxGroupIndex = segmentState.maxGroupIndex(candidateBase.groupKey) + if (sameGroupContinuation && maxGroupIndex != null && candidateBase.groupIndex <= maxGroupIndex) { + return false + } + + val segmentDurationSeconds = segmentState.projectedGroupDurationSeconds(candidateBase.groupKey, candidate) + return segmentDurationSeconds <= candidateBase.satellite.maxSurveyDuration.toLong() + } + + private fun CoverageCandidate.toContinuousCandidate(targetGeometry: Geometry): ContinuousCandidate = + ContinuousCandidate( + base = this, + leftLongitude = coveredEnvelope.minX, + rightLongitude = coveredEnvelope.maxX, + centerLongitude = (coveredEnvelope.minX + coveredEnvelope.maxX) / 2.0, + trackLongitude = normalizeLongitudeToTarget(slot.longitude, targetGeometry) + ) + + private fun buildSearchContext( + candidates: List, + groupsByKey: Map + ): CandidateSearchContext { + val longitudeOrdering = determineLongitudeOrdering(candidates) + val candidateIndexes = support.buildCandidateIndexes(candidates.map { it.base }) + val orderingLongitudeById = DoubleArray(candidates.size) { candidateId -> + orderingLongitude(candidates[candidateId], longitudeOrdering) + } + val sortedByLongitudeIds = candidates.indices + .sortedWith( + compareBy { orderingLongitudeById[it] } + .thenBy { candidates[it].rightLongitude } + .thenBy { candidates[it].base.slot.tn } + .thenBy { candidates[it].base.slot.slotNumber } + ) + .toIntArray() + val sortedLongitudePositionsById = IntArray(candidates.size) + sortedByLongitudeIds.forEachIndexed { index, candidateId -> + sortedLongitudePositionsById[candidateId] = index + } + + val groupForwardNeighborIds = Array(candidates.size) { IntArray(0) } + for ((_, group) in groupsByKey) { + val ids = group.candidateIds + for (index in ids.indices) { + val candidateId = ids[index] + val from = index + 1 + val to = minOf(ids.size, from + GROUP_FORWARD_NEIGHBOR_LIMIT) + groupForwardNeighborIds[candidateId] = + if (from >= to) IntArray(0) else ids.sliceArray(from until to) + } + } + + val spatialNeighborIds = buildSpatialNeighborIds(candidates, candidateIndexes.spatialIndex, longitudeOrdering) + + return CandidateSearchContext( + groupsByKey = groupsByKey, + candidateIndexes = candidateIndexes, + longitudeOrdering = longitudeOrdering, + orderingLongitudeById = orderingLongitudeById, + sortedByLongitudeIds = sortedByLongitudeIds, + sortedLongitudePositionsById = sortedLongitudePositionsById, + groupForwardNeighborIds = groupForwardNeighborIds, + spatialNeighborIds = spatialNeighborIds, + pairwiseCache = hashMapOf() + ) + } + + private fun buildSpatialNeighborIds( + candidates: List, + spatialIndex: STRtree, + longitudeOrdering: LongitudeOrdering + ): Array { + return Array(candidates.size) { candidateId -> + val candidate = candidates[candidateId] + val searchEnvelope = Envelope(candidate.base.coveredEnvelope) + searchEnvelope.expandBy( + max(candidate.base.coveredEnvelope.width * SPATIAL_ENVELOPE_EXPAND_FACTOR, MIN_SPATIAL_LONGITUDE_PADDING), + max(candidate.base.coveredEnvelope.height * SPATIAL_ENVELOPE_EXPAND_FACTOR, MIN_SPATIAL_LATITUDE_PADDING) + ) + + @Suppress("UNCHECKED_CAST") + val neighborIds = (spatialIndex.query(searchEnvelope) as List) + .mapNotNull { (it as? Int) } + .filter { it != candidateId } + .sortedWith( + compareBy { envelopeGap(candidate.base.coveredEnvelope, candidates[it].base.coveredEnvelope) } + .thenBy { abs(orderingLongitude(candidates[it], longitudeOrdering) - orderingLongitude(candidate, longitudeOrdering)) } + ) + .take(SPATIAL_NEIGHBOR_LIMIT) + neighborIds.toIntArray() + } + } + + private fun determineLongitudeOrdering(candidates: List): LongitudeOrdering { + val distinctTrackLongitudes = candidates + .map { it.trackLongitude } + .sorted() + .fold(mutableListOf()) { acc, value -> + if (acc.none { abs(it - value) <= LONGITUDE_EPS }) { + acc += value + } + acc + } + return LongitudeOrdering(useSlotLongitude = distinctTrackLongitudes.size > 1) + } + + private fun orderingLongitude(candidate: ContinuousCandidate, ordering: LongitudeOrdering): Double = + if (ordering.useSlotLongitude) candidate.trackLongitude else candidate.rightLongitude + + private fun orderingLongitudeById(candidateId: Int, searchContext: CandidateSearchContext): Double = + searchContext.orderingLongitudeById[candidateId] + + private fun normalizeLongitudeToTarget(longitude: Double, targetGeometry: Geometry): Double { + val targetCenter = (targetGeometry.envelopeInternal.minX + targetGeometry.envelopeInternal.maxX) / 2.0 + return listOf(longitude - 360.0, longitude, longitude + 360.0) + .minByOrNull { abs(it - targetCenter) } + ?: longitude + } + + private fun longitudeRelation( + previous: ContinuousCandidate, + candidate: ContinuousCandidate, + ordering: LongitudeOrdering + ): LongitudeRelation { + val delta = orderingLongitude(candidate, ordering) - orderingLongitude(previous, ordering) + return when { + delta > LONGITUDE_EPS -> LongitudeRelation.AHEAD + abs(delta) <= LONGITUDE_EPS -> LongitudeRelation.SAME + else -> LongitudeRelation.BACKWARD + } + } + + private fun pairwiseKey(previousId: Int, candidateId: Int): Long = + (previousId.toLong() shl 32) xor (candidateId.toLong() and 0xffffffffL) + + private fun envelopeGap(left: Envelope, right: Envelope): Double { + val dx = when { + left.maxX < right.minX -> right.minX - left.maxX + right.maxX < left.minX -> left.minX - right.maxX + else -> 0.0 + } + val dy = when { + left.maxY < right.minY -> right.minY - left.maxY + right.maxY < left.minY -> left.minY - right.maxY + else -> 0.0 + } + return kotlin.math.hypot(dx, dy) + } + + private enum class LongitudeRelation { + AHEAD, + SAME, + BACKWARD + } + + companion object { + private const val LONGITUDE_EPS = 1e-9 + private const val MEANINGFUL_GAIN_RATIO = 0.001 + + private const val START_GAIN_WEIGHT = 100.0 + private const val START_PREVIEW_GAIN_WEIGHT = 130.0 + private const val START_PREVIEW_LENGTH_WEIGHT = 18.0 + private const val START_RIGHT_REACH_WEIGHT = 1.0 + private const val START_LOOKAHEAD_STEPS = 4 + private const val START_PREVIEW_TOP_K = 12 + private const val START_CHEAP_GROUP_WEIGHT = 18.0 + private const val START_CHEAP_SPATIAL_WEIGHT = 4.0 + private const val START_CHEAP_RIGHT_REACH_WINDOW = 12 + + private const val GROUP_FORWARD_NEIGHBOR_LIMIT = 4 + private const val SPATIAL_NEIGHBOR_LIMIT = 16 + private const val LONGITUDE_NEIGHBOR_LIMIT = 20 + private const val EXTENDED_LONGITUDE_NEIGHBOR_LIMIT = 64 + private const val SPATIAL_ENVELOPE_EXPAND_FACTOR = 1.5 + private const val MIN_SPATIAL_LONGITUDE_PADDING = 0.75 + private const val MIN_SPATIAL_LATITUDE_PADDING = 0.5 + + private const val TRANSITION_GAIN_WEIGHT = 100.0 + private const val TRANSITION_OVERLAP_WEIGHT = 0.35 + private const val TRANSITION_BRANCH_WEIGHT = 12.0 + private const val TRANSITION_ROLL_WEIGHT = 1.5 + private const val TRANSITION_GAP_WEIGHT = 18.0 + private const val TRANSITION_RIGHT_PROGRESS_WEIGHT = 8.0 + private const val TRANSITION_CHAIN_PROGRESS_WEIGHT = 10.0 + private const val TRANSITION_CONTINUITY_BONUS = 28.0 + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/CoverageSolverSupport.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/CoverageSolverSupport.kt new file mode 100644 index 0000000..3b0d77a --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/CoverageSolverSupport.kt @@ -0,0 +1,816 @@ +package space.nstart.pcp.slots_service.model + +import org.locationtech.jts.geom.Envelope +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.geom.PrecisionModel +import org.locationtech.jts.index.strtree.STRtree +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.operation.overlayng.OverlayNG +import org.locationtech.jts.operation.overlayng.OverlayNGRobust +import org.locationtech.jts.precision.GeometryPrecisionReducer +import org.slf4j.Logger +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +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.configuration.CustomErrorException +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry +import java.time.Duration +import java.time.LocalDateTime + +internal data class CoverageSourceSlotCoverage( + val slot: SlotDTO, + val coveredGeometry: Geometry, + val coveredEnvelope: Envelope +) + +internal data class CoverageGroupKey( + val satelliteId: Long, + val cycle: Long, + val rollBucket: Int, + val revolutionSign: RevolutionSign +) + +internal data class CoverageGroup( + val candidateIds: IntArray +) + +internal data class CoverageSatelliteCandidates( + val sortedCandidateIds: IntArray, + val maxDurationSeconds: Long +) + +internal data class CoverageCandidateIndexes( + val satelliteCandidates: Map, + val spatialIndex: STRtree +) + +internal data class CoverageCandidate( + val id: Int, + val slot: SlotDTO, + val sourceCoverages: List, + val satellite: AbstractSatellite, + val coveredGeometry: Geometry, + val coveredEnvelope: Envelope, + val blockedStart: LocalDateTime, + val blockedEnd: LocalDateTime, + val durationSeconds: Long, + val groupKey: CoverageGroupKey, + val groupIndex: Int +) + +internal data class CoverageSlotChain( + val representative: SlotDTO, + val slots: List +) + +private data class CoverageSlotChainKey( + val satelliteId: Long, + val cycle: Long, + val rollBucket: Int, + val revolutionSign: RevolutionSign +) + +internal data class GeometryMergeAssessment( + val mergeable: Boolean, + val intersectionArea: Double? = null, + val geometryChecked: Boolean = true +) + +internal class CoverageSolverSupport( + private val logger: Logger, + private val satellitesById: Map +) { + + private data class SlotCoverage( + val id: Int, + val slot: SlotDTO, + val coveredGeometry: Geometry, + val coveredEnvelope: Envelope, + val area: Double + ) + + private val geometryFactory = GeometryFactory() + private val wktReader = WKTReader() + + fun parseTargetGeometry(targetWkt: String): Geometry { + val rawGeometry = try { + LongitudeWrapGeometry.normalizeToContinuous360(wktReader.read(targetWkt)) + } catch (e: Exception) { + throw IllegalArgumentException("Некорректный контур заявки: ${e.message}") + } + return normalizeGeometry(rawGeometry) + } + + fun mergeSlotsToChains(slots: List): List { + if (slots.isEmpty()) return emptyList() + + val buckets = mutableMapOf>() + for (slot in slots) { + val key = CoverageSlotChainKey( + satelliteId = slot.satelliteId, + cycle = slot.cycle, + rollBucket = rollBucket(slot.roll), + revolutionSign = slot.revolutionSign + ) + buckets.getOrPut(key) { mutableListOf() }.add(slot) + } + + val chains = mutableListOf() + for ((_, bucketSlots) in buckets) { + if (bucketSlots.isEmpty()) continue + bucketSlots.sortWith(compareBy({ it.tn }, { it.tk }, { it.slotNumber })) + val maxChainDurationSeconds = satellitesById[bucketSlots.first().satelliteId] + ?.maxSurveyDuration + ?.toLong() + ?.coerceAtLeast(0L) + ?: Long.MAX_VALUE + + var chainStart = bucketSlots.first().tn + var chainEnd = bucketSlots.first().tk + var chainContour = bucketSlots.first().contour + var chainSlots = mutableListOf(bucketSlots.first()) + + for (i in 1 until bucketSlots.size) { + val slot = bucketSlots[i] + val candidateEnd = if (slot.tk > chainEnd) slot.tk else chainEnd + val candidateDurationSeconds = Duration.between(chainStart, candidateEnd).seconds.coerceAtLeast(0L) + val exceedsMaxDuration = candidateDurationSeconds > maxChainDurationSeconds + + if (intervalsIntersect(chainStart, chainEnd, slot.tn, slot.tk) && !exceedsMaxDuration) { + val geometryMergeAssessment = assessMergeableGeometryIntersection( + leftContour = chainContour, + rightContour = slot.contour, + satelliteId = chainSlots.first().satelliteId + ) + if (!geometryMergeAssessment.mergeable) { + if (geometryMergeAssessment.geometryChecked) { + logChainMergeSkippedWithoutSufficientGeometry( + chainRepresentative = chainSlots.first(), + candidate = slot, + intersectionArea = geometryMergeAssessment.intersectionArea ?: 0.0 + ) + } + chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour)) + chainStart = slot.tn + chainEnd = slot.tk + chainContour = slot.contour + chainSlots = mutableListOf(slot) + continue + } + val mergedContour = unionContoursIfPolygon( + baseContour = chainContour, + contourToAdd = slot.contour, + chainRepresentative = chainSlots.first(), + candidate = slot + ) + if (mergedContour == null) { + chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour)) + chainStart = slot.tn + chainEnd = slot.tk + chainContour = slot.contour + chainSlots = mutableListOf(slot) + continue + } + chainSlots.add(slot) + chainEnd = candidateEnd + chainContour = mergedContour + } else { + chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour)) + chainStart = slot.tn + chainEnd = slot.tk + chainContour = slot.contour + chainSlots = mutableListOf(slot) + } + } + chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour)) + } + + return chains + } + + fun createSlotChain( + slots: List, + chainStart: LocalDateTime, + chainEnd: LocalDateTime, + chainContour: String + ): CoverageSlotChain { + val first = slots.first() + val chainState = when { + slots.any { it.state == SlotStatus.BOOKED } -> SlotStatus.BOOKED + slots.any { it.state == SlotStatus.AVAILABLE } -> SlotStatus.AVAILABLE + else -> first.state + } + + val representative = SlotDTO( + cycle = first.cycle, + satelliteId = first.satelliteId, + tn = chainStart, + tk = chainEnd, + roll = first.roll, + contour = chainContour, + revolution = first.revolution, + revolutionSign = first.revolutionSign, + slotNumber = slots.minOf { it.slotNumber }, + state = chainState, + latitude = first.latitude, + longitude = first.longitude + ) + + return CoverageSlotChain(representative = representative, slots = slots.toList()) + } + + fun assessMergeableGeometryIntersection( + leftContour: String, + rightContour: String, + satelliteId: Long + ): GeometryMergeAssessment { + return try { + val leftGeometry = wktReader.read(leftContour) + val rightGeometry = wktReader.read(rightContour) + logInvalidGeometryIfNeeded(satelliteId, "chain", leftGeometry) + logInvalidGeometryIfNeeded(satelliteId, "candidate", rightGeometry) + val intersectionArea = leftGeometry.intersection(rightGeometry).area + GeometryMergeAssessment( + mergeable = intersectionArea > AREA_EPS, + intersectionArea = intersectionArea + ) + } catch (e: Exception) { + logger.warn("Не удалось проверить пересечение геометрий цепочки solver: {}", e.message) + GeometryMergeAssessment(mergeable = false, geometryChecked = false) + } + } + + fun unionContoursIfPolygon( + baseContour: String, + contourToAdd: String, + chainRepresentative: SlotDTO, + candidate: SlotDTO + ): String? { + return try { + val unionGeometry = wktReader.read(baseContour).union(wktReader.read(contourToAdd)) + if (unionGeometry !is Polygon) { + logger.warn( + "Solver chain merge skipped because union produced non-polygon geometry: satelliteId={}, geometryType={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotNumber={}, slotNumber={}", + chainRepresentative.satelliteId, + unionGeometry.geometryType, + chainRepresentative.tn, + chainRepresentative.tk, + candidate.tn, + candidate.tk, + chainRepresentative.roll, + candidate.roll, + chainRepresentative.slotNumber, + candidate.slotNumber + ) + return null + } + unionGeometry.toText() + } catch (e: Exception) { + logger.warn("Не удалось объединить геометрию цепочки solver: {}", e.message) + null + } + } + + fun buildCandidates( + slotChains: List, + targetGeometry: Geometry + ): Pair, Map> { + val tmpCandidates = ArrayList(slotChains.size) + val groupBuckets = mutableMapOf>() + val targetEnvelope = targetGeometry.envelopeInternal + + for (chain in slotChains) { + val slot = chain.representative + val satellite = satellitesById[slot.satelliteId] ?: continue + var chainCoveredGeometry: Geometry? = null + val sourceCoverages = mutableListOf() + + for (sourceSlot in chain.slots) { + val slotGeometry = try { + normalizeGeometry( + LongitudeWrapGeometry.alignToReference( + wktReader.read(sourceSlot.contour), + targetGeometry + ) + ) + } catch (e: Exception) { + logger.warn("Пропуск слота {}:{}: {}", sourceSlot.satelliteId, sourceSlot.slotNumber, e.message) + continue + } + if (!slotGeometry.envelopeInternal.intersects(targetEnvelope)) { + continue + } + val coveredGeometry = safeIntersection(slotGeometry, targetGeometry) + if (coveredGeometry.isEmpty || coveredGeometry.area <= AREA_EPS) { + continue + } + sourceCoverages.add( + CoverageSourceSlotCoverage( + slot = sourceSlot, + coveredGeometry = coveredGeometry, + coveredEnvelope = coveredGeometry.envelopeInternal + ) + ) + + chainCoveredGeometry = if (chainCoveredGeometry == null) { + coveredGeometry + } else { + safeUnion(chainCoveredGeometry!!, coveredGeometry) + } + } + + val coveredGeometry = chainCoveredGeometry ?: continue + if (coveredGeometry.isEmpty || coveredGeometry.area <= AREA_EPS) { + continue + } + val rollBucket = rollBucket(slot.roll) + val groupKey = CoverageGroupKey( + satelliteId = slot.satelliteId, + cycle = slot.cycle, + rollBucket = rollBucket, + revolutionSign = slot.revolutionSign + ) + val candidateId = tmpCandidates.size + val mmi = satellite.mmi + val candidate = CoverageCandidate( + id = candidateId, + slot = slot, + sourceCoverages = sourceCoverages, + satellite = satellite, + coveredGeometry = coveredGeometry, + coveredEnvelope = coveredGeometry.envelopeInternal, + blockedStart = slot.tn.minusSeconds(mmi), + blockedEnd = slot.tk.plusSeconds(mmi), + durationSeconds = Duration.between(slot.tn, slot.tk).seconds.coerceAtLeast(0L), + groupKey = groupKey, + groupIndex = 0 + ) + tmpCandidates.add(candidate) + groupBuckets.getOrPut(groupKey) { mutableListOf() }.add(candidateId) + } + + val finalizedCandidates = tmpCandidates.toMutableList() + val groups = mutableMapOf() + + for ((key, ids) in groupBuckets) { + ids.sortWith(compareBy { finalizedCandidates[it].slot.tn } + .thenBy { finalizedCandidates[it].slot.tk } + .thenBy { finalizedCandidates[it].slot.slotNumber }) + ids.forEachIndexed { index, id -> + val old = finalizedCandidates[id] + finalizedCandidates[id] = old.copy(groupIndex = index) + } + groups[key] = CoverageGroup(ids.toIntArray()) + } + + return finalizedCandidates to groups + } + + fun buildSatelliteCandidatesIndex(candidates: List): Map { + val candidatesBySatellite = mutableMapOf>() + val maxDurationBySatellite = mutableMapOf() + + for (candidate in candidates) { + val satelliteId = candidate.slot.satelliteId + candidatesBySatellite.getOrPut(satelliteId) { mutableListOf() }.add(candidate.id) + val maxDuration = maxDurationBySatellite[satelliteId] ?: 0L + if (candidate.durationSeconds > maxDuration) { + maxDurationBySatellite[satelliteId] = candidate.durationSeconds + } + } + + val result = mutableMapOf() + for ((satelliteId, ids) in candidatesBySatellite) { + ids.sortWith(compareBy { candidates[it].slot.tn } + .thenBy { candidates[it].slot.tk } + .thenBy { candidates[it].slot.slotNumber }) + result[satelliteId] = CoverageSatelliteCandidates( + sortedCandidateIds = ids.toIntArray(), + maxDurationSeconds = maxDurationBySatellite[satelliteId] ?: 0L + ) + } + + return result + } + + fun buildCandidateIndexes(candidates: List): CoverageCandidateIndexes { + val spatialIndex = STRtree(candidates.size) + for (candidate in candidates) { + spatialIndex.insert(candidate.coveredEnvelope, candidate.id) + } + spatialIndex.build() + return CoverageCandidateIndexes( + satelliteCandidates = buildSatelliteCandidatesIndex(candidates), + spatialIndex = spatialIndex + ) + } + + fun deactivateConflicts( + selected: List, + candidatesBySatellite: Map, + candidates: List, + active: BooleanArray + ) { + if (selected.isEmpty()) return + for (sel in selected) { + val satelliteCandidates = candidatesBySatellite[sel.slot.satelliteId] ?: continue + val ids = satelliteCandidates.sortedCandidateIds + if (ids.isEmpty()) continue + + val startBound = sel.blockedStart.minusSeconds(satelliteCandidates.maxDurationSeconds) + val from = lowerBoundByStart(ids, candidates, startBound) + val to = upperBoundByStart(ids, candidates, sel.blockedEnd) + + for (idx in from until to) { + val candidateId = ids[idx] + if (!active[candidateId]) continue + val candidate = candidates[candidateId] + if (intervalsIntersect(candidate.slot.tn, candidate.slot.tk, sel.blockedStart, sel.blockedEnd)) { + active[candidate.id] = false + } + } + } + } + + fun removeRedundant(selected: List): List { + if (selected.isEmpty()) return emptyList() + val kept = selected.toMutableList() + val targetArea = unionGeometries(kept.map { it.coveredGeometry }).area + + var changed = true + while (changed) { + changed = false + val removable = kept + .filter { it.slot.state != SlotStatus.BOOKED } + .sortedBy { it.coveredGeometry.area } + val indexById = kept + .withIndex() + .associate { (index, candidate) -> candidate.id to index } + val prefixUnions = buildPrefixUnions(kept.map { it.coveredGeometry }) + val suffixUnions = buildSuffixUnions(kept.map { it.coveredGeometry }) + + for (candidate in removable) { + val candidateIndex = indexById[candidate.id] ?: continue + val areaWithout = safeUnion( + prefixUnions[candidateIndex], + suffixUnions[candidateIndex + 1] + ).area + if (targetArea - areaWithout <= AREA_EPS) { + kept.removeIf { it.id == candidate.id } + changed = true + break + } + } + } + return kept + } + + fun collectUsefulSlotsFromSelectedChains( + selectedCandidates: List, + targetGeometry: Geometry + ): List { + if (selectedCandidates.isEmpty()) return emptyList() + + val orderedCandidates = selectedCandidates.sortedWith(compareBy( + { it.slot.tn }, + { it.slot.satelliteId }, + { it.slot.cycle }, + { it.slot.slotNumber } + )) + + val resultSlots = mutableListOf() + val seen = hashSetOf() + var uncovered = targetGeometry + var uncoveredEnvelope = targetGeometry.envelopeInternal + + for (candidate in orderedCandidates) { + val orderedSources = candidate.sourceCoverages.sortedWith(compareBy( + { it.slot.tn }, + { it.slot.satelliteId }, + { it.slot.cycle }, + { it.slot.slotNumber } + )) + + for (source in orderedSources) { + if (!source.coveredEnvelope.intersects(uncoveredEnvelope)) continue + val gainArea = safeIntersection(source.coveredGeometry, uncovered).area + if (gainArea <= AREA_EPS) continue + + val key = slotKey(source.slot) + if (seen.add(key)) { + resultSlots.add(source.slot) + } + + uncovered = safeDifference(uncovered, source.coveredGeometry) + uncoveredEnvelope = uncovered.envelopeInternal + if (uncovered.isEmpty || uncovered.area <= AREA_EPS) { + return resultSlots + } + } + } + + return resultSlots + } + + fun buildPreparedSourceCoverageMap(selectedCandidates: List): Map { + if (selectedCandidates.isEmpty()) { + return emptyMap() + } + val result = linkedMapOf() + for (candidate in selectedCandidates) { + for (sourceCoverage in candidate.sourceCoverages) { + result.putIfAbsent(slotKey(sourceCoverage.slot), sourceCoverage) + } + } + return result + } + + fun removeNonCoveringAndFullyCoveredResultSlots( + slots: List, + targetGeometry: Geometry, + preparedSourceCoverages: Map = emptyMap() + ): List { + if (slots.isEmpty()) return emptyList() + + val targetEnvelope = targetGeometry.envelopeInternal + val slotCoverages = ArrayList(slots.size) + + for (slot in slots) { + val preparedCoverage = preparedSourceCoverages[slotKey(slot)] + val coveredGeometry = if (preparedCoverage != null) { + preparedCoverage.coveredGeometry + } else { + try { + val slotGeometry = normalizeGeometry( + LongitudeWrapGeometry.alignToReference( + wktReader.read(slot.contour), + targetGeometry + ) + ) + if (!slotGeometry.envelopeInternal.intersects(targetEnvelope)) { + geometryFactory.createPolygon() + } else { + safeIntersection(slotGeometry, targetGeometry) + } + } catch (e: Exception) { + logger.warn("Исключен слот {}:{}: некорректный контур ({})", slot.satelliteId, slot.slotNumber, e.message) + geometryFactory.createPolygon() + } + } + + if (coveredGeometry.isEmpty || coveredGeometry.area <= AREA_EPS) continue + slotCoverages.add( + SlotCoverage( + id = slotCoverages.size, + slot = slot, + coveredGeometry = coveredGeometry, + coveredEnvelope = coveredGeometry.envelopeInternal, + area = coveredGeometry.area + ) + ) + } + + if (slotCoverages.size <= 1) return slotCoverages.map { it.slot } + + val index = STRtree(slotCoverages.size) + for (coverage in slotCoverages) { + index.insert(coverage.coveredEnvelope, coverage) + } + index.build() + + val active = BooleanArray(slotCoverages.size) { true } + val orderedByArea = slotCoverages + .indices + .sortedWith(compareBy { slotCoverages[it].area } + .thenBy { slotCoverages[it].slot.tn } + .thenBy { slotCoverages[it].slot.satelliteId } + .thenBy { slotCoverages[it].slot.cycle } + .thenBy { slotCoverages[it].slot.slotNumber }) + + for (idx in orderedByArea) { + if (!active[idx]) continue + val candidate = slotCoverages[idx] + + val queried = index.query(candidate.coveredEnvelope) + val coverers = ArrayList(queried.size) + for (item in queried) { + val coverage = item as? SlotCoverage ?: continue + if (coverage.id == candidate.id) continue + if (!active[coverage.id]) continue + if (!coverage.coveredEnvelope.intersects(candidate.coveredEnvelope)) continue + coverers.add(coverage) + } + if (coverers.isEmpty()) continue + + var fullyCovered = false + for (coverer in coverers) { + if (coverer.coveredGeometry.covers(candidate.coveredGeometry)) { + fullyCovered = true + break + } + } + if (fullyCovered) { + active[idx] = false + continue + } + + val orderedCoverers = coverers.sortedByDescending { + envelopeIntersectionArea(it.coveredEnvelope, candidate.coveredEnvelope) + } + + var unionGeometry: Geometry? = null + val unionEnvelope = Envelope() + for (coverer in orderedCoverers) { + unionGeometry = if (unionGeometry == null) { + coverer.coveredGeometry + } else { + safeUnion(unionGeometry!!, coverer.coveredGeometry) + } + + unionEnvelope.expandToInclude(coverer.coveredEnvelope) + if (!unionEnvelope.contains(candidate.coveredEnvelope)) continue + + val uncoveredPart = safeDifference(candidate.coveredGeometry, unionGeometry!!) + if (uncoveredPart.isEmpty || uncoveredPart.area <= AREA_EPS) { + active[idx] = false + break + } + } + } + + return slotCoverages + .filter { active[it.id] } + .map { it.slot } + } + + fun unionGeometries(geometries: List): Geometry { + if (geometries.isEmpty()) return geometryFactory.createPolygon() + var union = geometries.first() + for (i in 1 until geometries.size) { + union = safeUnion(union, geometries[i]) + } + return union + } + + fun safeIntersection(a: Geometry, b: Geometry): Geometry { + if (a.isEmpty || b.isEmpty) return geometryFactory.createPolygon() + if (!a.envelopeInternal.intersects(b.envelopeInternal)) return geometryFactory.createPolygon() + return safeOverlay(a, b, OverlayNG.INTERSECTION) + } + + fun safeDifference(a: Geometry, b: Geometry): Geometry { + if (a.isEmpty) return geometryFactory.createPolygon() + if (b.isEmpty) return a + if (!a.envelopeInternal.intersects(b.envelopeInternal)) return a + return safeOverlay(a, b, OverlayNG.DIFFERENCE) + } + + fun safeUnion(a: Geometry, b: Geometry): Geometry { + if (a.isEmpty) return b + if (b.isEmpty) return a + return safeOverlay(a, b, OverlayNG.UNION) + } + + fun normalizeGeometry(geometry: Geometry): Geometry { + if (geometry.isEmpty) return geometryFactory.createPolygon() + return if (geometry.isValid) geometry else geometry.buffer(0.0) + } + + fun intervalsIntersect( + start1: LocalDateTime, + end1: LocalDateTime, + start2: LocalDateTime, + end2: LocalDateTime + ) = start1 <= end2 && start2 <= end1 + + fun rollBucket(roll: Double): Int = (roll / ROLL_EPS).toInt() + + private fun lowerBoundByStart(ids: IntArray, candidates: List, threshold: LocalDateTime): Int { + var low = 0 + var high = ids.size + while (low < high) { + val mid = (low + high) ushr 1 + if (candidates[ids[mid]].slot.tn < threshold) { + low = mid + 1 + } else { + high = mid + } + } + return low + } + + private fun upperBoundByStart(ids: IntArray, candidates: List, threshold: LocalDateTime): Int { + var low = 0 + var high = ids.size + while (low < high) { + val mid = (low + high) ushr 1 + if (candidates[ids[mid]].slot.tn <= threshold) { + low = mid + 1 + } else { + high = mid + } + } + return low + } + + private fun slotKey(slot: SlotDTO): String = + "${slot.satelliteId}_${slot.cycle}_${slot.slotNumber}_${slot.tn}_${slot.tk}_${slot.roll}" + + private fun buildPrefixUnions(geometries: List): List { + val prefix = ArrayList(geometries.size + 1) + prefix.add(geometryFactory.createPolygon()) + for (geometry in geometries) { + prefix.add(safeUnion(prefix.last(), geometry)) + } + return prefix + } + + private fun buildSuffixUnions(geometries: List): List { + val suffix = MutableList(geometries.size + 1) { geometryFactory.createPolygon() as Geometry } + for (index in geometries.indices.reversed()) { + suffix[index] = safeUnion(geometries[index], suffix[index + 1]) + } + return suffix + } + + private fun envelopeIntersectionArea(a: Envelope, b: Envelope): Double { + val minX = maxOf(a.minX, b.minX) + val maxX = minOf(a.maxX, b.maxX) + if (maxX <= minX) return 0.0 + + val minY = maxOf(a.minY, b.minY) + val maxY = minOf(a.maxY, b.maxY) + if (maxY <= minY) return 0.0 + + return (maxX - minX) * (maxY - minY) + } + + private fun safeOverlay(a: Geometry, b: Geometry, opCode: Int): Geometry { + val g1 = normalizeGeometry(a) + val g2 = normalizeGeometry(b) + + return try { + normalizeGeometry(OverlayNGRobust.overlay(g1, g2, opCode)) + } catch (_: Exception) { + val snapped1 = snapGeometry(g1, 1e-6) + val snapped2 = snapGeometry(g2, 1e-6) + try { + normalizeGeometry(OverlayNGRobust.overlay(snapped1, snapped2, opCode)) + } catch (_: Exception) { + val buffered1 = normalizeGeometry(snapped1.buffer(0.0)) + val buffered2 = normalizeGeometry(snapped2.buffer(0.0)) + normalizeGeometry(OverlayNGRobust.overlay(buffered1, buffered2, opCode)) + } + } + } + + private fun snapGeometry(geometry: Geometry, tolerance: Double): Geometry { + val scale = 1.0 / tolerance + return normalizeGeometry(GeometryPrecisionReducer.reduce(geometry, PrecisionModel(scale))) + } + + private fun logInvalidGeometryIfNeeded( + satelliteId: Long, + geometryRole: String, + geometry: Geometry + ) { + if (geometry.isValid) { + return + } + logger.warn( + "Solver chain merge geometry is invalid: satelliteId={}, geometryRole={}, geometryType={}", + satelliteId, + geometryRole, + geometry.geometryType + ) + } + + private fun logChainMergeSkippedWithoutSufficientGeometry( + chainRepresentative: SlotDTO, + candidate: SlotDTO, + intersectionArea: Double + ) { + logger.warn( + "Solver chain merge skipped because contours lack sufficient areal intersection despite temporal overlap: satelliteId={}, intersectionArea={}, epsilon={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotNumber={}, slotNumber={}", + chainRepresentative.satelliteId, + intersectionArea, + AREA_EPS, + chainRepresentative.tn, + chainRepresentative.tk, + candidate.tn, + candidate.tk, + chainRepresentative.roll, + candidate.roll, + chainRepresentative.slotNumber, + candidate.slotNumber + ) + } + + companion object { + const val AREA_EPS = 1e-8 + const val ROLL_EPS = 0.01 + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt new file mode 100644 index 0000000..7ea770f --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.slots_service.model + +import org.locationtech.jts.geom.Geometry +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +class Mar ( + val sat : Long = 0, + val tn : LocalDateTime = LocalDateTime.now(), + val tk : LocalDateTime = LocalDateTime.now(), + val roll : Double = 0.0, + val contour : String = "", + var poly : Geometry? = null, + val vit : Long = 0, + val revolutionSign: RevolutionSign = RevolutionSign.ASC, + var cycle : Int = 1, + var slotNum : Long = 0, + val latitude : Double = 0.0, + val longitude : Double = 0.0, +){ +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/OptimalReqCoverSolver.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/OptimalReqCoverSolver.kt new file mode 100644 index 0000000..408d460 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/OptimalReqCoverSolver.kt @@ -0,0 +1,358 @@ +package space.nstart.pcp.slots_service.model + +import org.locationtech.jts.geom.Envelope +import org.locationtech.jts.geom.Geometry +import org.slf4j.Logger +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.configuration.CustomErrorException +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import java.time.LocalDateTime +import java.util.PriorityQueue +import kotlin.jvm.Throws +import kotlin.math.abs +import kotlin.system.measureTimeMillis + +class OptimalReqCoverSolver( + private val logger: Logger, + private val satellitesById: Map +) { + + private data class QueueItem( + val candidateId: Int, + val gainArea: Double, + val tn: LocalDateTime + ) + + private val support = CoverageSolverSupport(logger, satellitesById) + + @Throws(CustomErrorException::class) + fun select(targetWkt: String, slots: List): List { + lateinit var slotChains: List + val mergeChainsMs = measureTimeMillis { + slotChains = mergeSlotsToChains(slots) + } + logger.info("Слотов после объединения в цепочки ${slotChains.size}") + +// if (slotChains.size > 25000) +// throw CustomErrorException("Для расчета покрытия получено больше 5000 слотов! Расчет отменен") + + if (slotChains.isEmpty()) { + logger.info( + "coverageSolver metrics: inputSlots={}, chains=0, candidates=0, resultSlots=0, mergeChainsMs={}, totalMs={}", + slots.size, + mergeChainsMs, + mergeChainsMs + ) + return emptyList() + } + + lateinit var targetGeometry: Geometry + val parseTargetMs = measureTimeMillis { + targetGeometry = support.parseTargetGeometry(targetWkt) + } + if (targetGeometry.isEmpty || targetGeometry.area <= CoverageSolverSupport.AREA_EPS) { + logger.info( + "coverageSolver metrics: inputSlots={}, chains={}, candidates=0, resultSlots=0, mergeChainsMs={}, parseTargetMs={}, totalMs={}", + slots.size, + slotChains.size, + mergeChainsMs, + parseTargetMs, + mergeChainsMs + parseTargetMs + ) + return emptyList() + } + + lateinit var preparedCandidates: Pair, Map> + val buildCandidatesMs = measureTimeMillis { + preparedCandidates = support.buildCandidates(slotChains, targetGeometry) + } + val candidates = preparedCandidates.first + if (candidates.isEmpty()) { + logger.info( + "coverageSolver metrics: inputSlots={}, chains={}, candidates=0, resultSlots=0, mergeChainsMs={}, parseTargetMs={}, buildCandidatesMs={}, totalMs={}", + slots.size, + slotChains.size, + mergeChainsMs, + parseTargetMs, + buildCandidatesMs, + mergeChainsMs + parseTargetMs + buildCandidatesMs + ) + return emptyList() + } + val groupsByKey = preparedCandidates.second + + lateinit var satelliteCandidates: Map + val buildIndexMs = measureTimeMillis { + satelliteCandidates = support.buildSatelliteCandidatesIndex(candidates) + } + + val active = BooleanArray(candidates.size) { true } + val selected = mutableListOf() + var uncovered = targetGeometry + var uncoveredEnvelope = targetGeometry.envelopeInternal + var pqPolls = 0 + var lazyRequeues = 0 + var zeroGainDeactivated = 0 + var envelopeMissDeactivated = 0 + var acceptedCandidates = 0 + var conflictDeactivateCalls = 0 + + val pq = PriorityQueue(compareByDescending { it.gainArea } + .thenBy { it.tn }) + + val mainLoopMs = measureTimeMillis { + for (candidate in candidates) { + if (candidate.slot.state == SlotStatus.BOOKED) { + selected.add(candidate) + acceptedCandidates++ + uncovered = support.safeDifference(uncovered, candidate.coveredGeometry) + uncoveredEnvelope = uncovered.envelopeInternal + active[candidate.id] = false + } else if (candidate.slot.state == SlotStatus.AVAILABLE) { + pq.add(QueueItem(candidate.id, candidate.coveredGeometry.area, candidate.slot.tn)) + } else { + active[candidate.id] = false + } + } + + if (selected.isNotEmpty()) { + conflictDeactivateCalls++ + support.deactivateConflicts(selected, satelliteCandidates, candidates, active) + } + + while (pq.isNotEmpty()) { + pqPolls++ + val item = pq.poll() + if (!active[item.candidateId]) { + continue + } + val candidate = candidates[item.candidateId] + if (!candidate.coveredEnvelope.intersects(uncoveredEnvelope)) { + active[item.candidateId] = false + envelopeMissDeactivated++ + continue + } + + val gainArea = support.safeIntersection(candidate.coveredGeometry, uncovered).area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + active[item.candidateId] = false + zeroGainDeactivated++ + continue + } + + // lazy update if gain dropped significantly + if (gainArea + CoverageSolverSupport.AREA_EPS < item.gainArea) { + lazyRequeues++ + pq.add(QueueItem(candidate.id, gainArea, candidate.slot.tn)) + continue + } + + val chain = buildGreedyChain(candidate, groupsByKey, candidates, active, uncovered, uncoveredEnvelope) + if (chain.isEmpty()) { + active[item.candidateId] = false + continue + } + + val acceptedChain = mutableListOf() + chain.forEach { chosen -> + if (active[chosen.id]) { + if (!chosen.coveredEnvelope.intersects(uncoveredEnvelope)) { + active[chosen.id] = false + envelopeMissDeactivated++ + return@forEach + } + val gainArea = support.safeIntersection(chosen.coveredGeometry, uncovered).area + if (gainArea <= CoverageSolverSupport.AREA_EPS) { + active[chosen.id] = false + zeroGainDeactivated++ + return@forEach + } + selected.add(chosen) + acceptedCandidates++ + acceptedChain.add(chosen) + uncovered = support.safeDifference(uncovered, chosen.coveredGeometry) + uncoveredEnvelope = uncovered.envelopeInternal + active[chosen.id] = false + } + } + + if (acceptedChain.isNotEmpty()) { + conflictDeactivateCalls++ + support.deactivateConflicts(acceptedChain, satelliteCandidates, candidates, active) + } + + if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) { + break + } + } + } + + lateinit var nonRedundantCandidates: List + val removeRedundantMs = measureTimeMillis { + nonRedundantCandidates = support.removeRedundant(selected) + } + lateinit var preparedSourceCoverages: Map + val sourceCoverageMapMs = measureTimeMillis { + preparedSourceCoverages = support.buildPreparedSourceCoverageMap(nonRedundantCandidates) + } + lateinit var resultSlots: List + val collectUsefulSlotsMs = measureTimeMillis { + resultSlots = support.collectUsefulSlotsFromSelectedChains(nonRedundantCandidates, targetGeometry) + } + lateinit var filteredResult: List + val finalFilterMs = measureTimeMillis { + filteredResult = support.removeNonCoveringAndFullyCoveredResultSlots( + slots = resultSlots, + targetGeometry = targetGeometry, + preparedSourceCoverages = preparedSourceCoverages + ) + } + val totalMs = mergeChainsMs + parseTargetMs + buildCandidatesMs + buildIndexMs + mainLoopMs + + removeRedundantMs + sourceCoverageMapMs + collectUsefulSlotsMs + finalFilterMs + val coveragePercent = 100 * (targetGeometry.area - uncovered.area) / targetGeometry.area + logger.info( + "coverageSolver metrics: inputSlots={}, chains={}, candidates={}, groups={}, selectedCandidates={}, nonRedundantCandidates={}, sourceCoverageCache={}, sourceSlots={} -> {}, coveragePercent={}, pqPolls={}, lazyRequeues={}, zeroGainDeactivated={}, envelopeMissDeactivated={}, conflictDeactivateCalls={}, acceptedCandidates={}, mergeChainsMs={}, parseTargetMs={}, buildCandidatesMs={}, buildIndexMs={}, mainLoopMs={}, removeRedundantMs={}, sourceCoverageMapMs={}, collectUsefulSlotsMs={}, finalFilterMs={}, totalMs={}", + slots.size, + slotChains.size, + candidates.size, + groupsByKey.size, + selected.size, + nonRedundantCandidates.size, + preparedSourceCoverages.size, + resultSlots.size, + filteredResult.size, + coveragePercent, + pqPolls, + lazyRequeues, + zeroGainDeactivated, + envelopeMissDeactivated, + conflictDeactivateCalls, + acceptedCandidates, + mergeChainsMs, + parseTargetMs, + buildCandidatesMs, + buildIndexMs, + mainLoopMs, + removeRedundantMs, + sourceCoverageMapMs, + collectUsefulSlotsMs, + finalFilterMs, + totalMs + ) + return filteredResult + .sortedWith(compareBy( + { it.tn }, + { it.satelliteId }, + { it.cycle }, + { it.slotNumber } + )) + } + + private fun mergeSlotsToChains(slots: List): List = + support.mergeSlotsToChains(slots) + + private fun createSlotChain( + slots: List, + chainStart: LocalDateTime, + chainEnd: LocalDateTime, + chainContour: String + ): CoverageSlotChain = support.createSlotChain(slots, chainStart, chainEnd, chainContour) + + private fun assessMergeableGeometryIntersection( + leftContour: String, + rightContour: String, + satelliteId: Long + ): GeometryMergeAssessment = support.assessMergeableGeometryIntersection(leftContour, rightContour, satelliteId) + + private fun unionContoursIfPolygon( + baseContour: String, + contourToAdd: String, + chainRepresentative: SlotDTO, + candidate: SlotDTO + ): String? = support.unionContoursIfPolygon(baseContour, contourToAdd, chainRepresentative, candidate) + + private fun buildGreedyChain( + anchor: CoverageCandidate, + groupsByKey: Map, + candidates: List, + active: BooleanArray, + uncovered: Geometry, + uncoveredEnvelope: Envelope + ): List { + val group = groupsByKey[anchor.groupKey] ?: return listOf(anchor) + val ids = group.candidateIds + var left = anchor.groupIndex + var right = anchor.groupIndex + var duration = anchor.durationSeconds + val maxDuration = anchor.satellite.maxSurveyDuration.toLong().coerceAtLeast(anchor.durationSeconds) + + val chain = mutableListOf(anchor) + + var expanded = true + while (expanded) { + expanded = false + + val leftCandidate = if (left > 0) candidates[ids[left - 1]] else null + val rightCandidate = if (right < ids.lastIndex) candidates[ids[right + 1]] else null + + val chooseLeft = shouldExtend(leftCandidate, anchor, active, duration, maxDuration, uncovered, uncoveredEnvelope) + val chooseRight = shouldExtend(rightCandidate, anchor, active, duration, maxDuration, uncovered, uncoveredEnvelope) + + val chosen = when { + chooseLeft == null && chooseRight == null -> null + chooseLeft != null && chooseRight == null -> chooseLeft.second + chooseLeft == null && chooseRight != null -> chooseRight.second + else -> if (chooseLeft!!.first >= chooseRight!!.first) chooseLeft.second else chooseRight!!.second + } + + if (chosen != null) { + val candidate = chosen + chain.add(candidate) + duration += candidate.durationSeconds + if (candidate.id == leftCandidate?.id) { + left-- + } else if (candidate.id == rightCandidate?.id) { + right++ + } + expanded = true + } + } + + return chain.sortedBy { it.slot.tn } + } + + private fun shouldExtend( + candidate: CoverageCandidate?, + anchor: CoverageCandidate, + active: BooleanArray, + currentDuration: Long, + maxDuration: Long, + uncovered: Geometry, + uncoveredEnvelope: Envelope + ): Pair? { + if (candidate == null) return null + if (!active[candidate.id]) return null + if (!canChainNeighbours(anchor, candidate)) return null + if (currentDuration + candidate.durationSeconds > maxDuration) return null + if (!candidate.coveredEnvelope.intersects(uncoveredEnvelope)) return null + + val gain = support.safeIntersection(candidate.coveredGeometry, uncovered).area + if (gain <= CoverageSolverSupport.AREA_EPS) return null + + val score = gain + return score to candidate + } + + private fun canChainNeighbours(left: CoverageCandidate, right: CoverageCandidate): Boolean { + if (left.slot.satelliteId != right.slot.satelliteId) return false + if (left.slot.cycle != right.slot.cycle) return false + if (abs(left.slot.roll - right.slot.roll) >= CoverageSolverSupport.ROLL_EPS) return false + return support.intervalsIntersect( + right.slot.tn, + right.slot.tk, + left.blockedStart, + left.blockedEnd + ) + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/PolygonCoverageSolver.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/PolygonCoverageSolver.kt new file mode 100644 index 0000000..4af7221 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/PolygonCoverageSolver.kt @@ -0,0 +1,349 @@ +package space.nstart.pcp.slots_service.model + + +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryCollection +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.geom.PrecisionModel +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.operation.overlayng.OverlayNG +import org.locationtech.jts.operation.union.CascadedPolygonUnion +import org.locationtech.jts.precision.GeometryPrecisionReducer +import org.locationtech.jts.simplify.TopologyPreservingSimplifier +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import java.time.Duration +import java.time.LocalDateTime +import kotlin.math.abs + +data class TimedPolygon( + val geometry: Geometry, + val time: LocalDateTime, + val id: String +) + +data class PolygonCoverage( + val selectedPolygons: List, + val coverageArea: Double, + val coveragePercentage: Double, + val uncoveredArea: Geometry? +) + +class PolygonCoverageSolver { + + private val geometryFactory = GeometryFactory() + private val wktReader = WKTReader() + + + // Вспомогательный объект для безопасных геометрических операций + private object GeometryUtils { + private const val SNAP_TOLERANCE = 1e-6 + private const val MIN_AREA = 1e-8 + private val geometryFactory = GeometryFactory() + + // Безопасное пересечение через OverlayNG + fun safeIntersection(g1: Geometry, g2: Geometry): Geometry { + return try { + val result = OverlayNG.overlay(g1, g2, OverlayNG.INTERSECTION) + validateAndNormalize(result) + } catch (e: Exception) { + recoverTopology(g1, g2) { a, b -> OverlayNG.overlay(a, b, OverlayNG.INTERSECTION) } + } + } + + // Безопасная разность + fun safeDifference(g1: Geometry, g2: Geometry): Geometry { + return try { + val result = OverlayNG.overlay(g1, g2, OverlayNG.DIFFERENCE) + validateAndNormalize(result) + } catch (e: Exception) { + recoverTopology(g1, g2) { a, b -> OverlayNG.overlay(a, b, OverlayNG.DIFFERENCE) } + } + } + + // Безопасное объединение списка геометрий (рекомендуемый подход для JTS 1.19+) + fun safeUnion(geometries: List): Geometry { + if (geometries.isEmpty()) return geometryFactory.createPolygon() + if (geometries.size == 1) return validateAndNormalize(geometries[0]) + + // Используем CascadedPolygonUnion — оптимальный для больших наборов + return try { + val result = CascadedPolygonUnion.union(geometries) + validateAndNormalize(result) + } catch (e: Exception) { + // Резерв: поэлементное объединение + var union = validateAndNormalize(geometries[0]) + for (i in 1 until geometries.size) { + union = safeUnion(union, geometries[i]) + } + union + } + } + + // Безопасное объединение двух геометрий + fun safeUnion(g1: Geometry, g2: Geometry): Geometry { + return try { + val result = OverlayNG.overlay(g1, g2, OverlayNG.UNION) + validateAndNormalize(result) + } catch (e: Exception) { + recoverTopology(g1, g2) { a, b -> OverlayNG.overlay(a, b, OverlayNG.UNION) } + } + } + + // Механизм восстановления топологии при ошибках + private fun recoverTopology( + g1: Geometry, + g2: Geometry, + operation: (Geometry, Geometry) -> Geometry + ): Geometry { + // Уровень 1: снэппинг к сетке + try { + val snapped1 = snapToGrid(g1, SNAP_TOLERANCE) + val snapped2 = snapToGrid(g2, SNAP_TOLERANCE) + return validateAndNormalize(operation(snapped1, snapped2)) + } catch (e1: Exception) { + // Уровень 2: упрощение с сохранением топологии + try { + val simplified1 = TopologyPreservingSimplifier.simplify(snapToGrid(g1, SNAP_TOLERANCE), SNAP_TOLERANCE * 0.1) + val simplified2 = TopologyPreservingSimplifier.simplify(snapToGrid(g2, SNAP_TOLERANCE), SNAP_TOLERANCE * 0.1) + return validateAndNormalize(operation(simplified1, simplified2)) + } catch (e2: Exception) { + // Уровень 3: буферизация как крайняя мера + return validateAndNormalize(g1.buffer(SNAP_TOLERANCE * 0.01).union(g2.buffer(SNAP_TOLERANCE * 0.01))) + } + } + } + + // Снэппинг к сетке + private fun snapToGrid(geom: Geometry, tolerance: Double): Geometry { + val gridScale = 1.0 / tolerance + return GeometryPrecisionReducer.reduce(geom, PrecisionModel(gridScale)) + } + + // Валидация и нормализация геометрии + fun validateAndNormalize(geom: Geometry): Geometry { + if (geom.isEmpty || geom.area < MIN_AREA) { + return geometryFactory.createPolygon() + } + + // Исправление невалидных геометрий + val validGeom = if (!geom.isValid) { + try { + // Для JTS 1.18+ доступен makeValid() + geom.buffer(0.0) + } catch (e: Exception) { + // Резервный вариант для всех версий + geom.buffer(0.0) + } + } else { + geom + } + + // Очистка вырожденных частей для MultiPolygon + return when (validGeom) { + is Polygon -> if (validGeom.area > MIN_AREA) validGeom else geometryFactory.createPolygon() + is MultiPolygon -> { + val validParts = (0 until validGeom.numGeometries) + .map { validGeom.getGeometryN(it) } + .filter { it is Polygon && it.area > MIN_AREA } + .map { it as Polygon } + + when { + validParts.isEmpty() -> geometryFactory.createPolygon() + validParts.size == 1 -> validParts[0] + else -> geometryFactory.createMultiPolygon(validParts.toTypedArray()) + } + } + else -> { + // Для других типов (LineString, Point) — преобразуем в буфер + if (validGeom is GeometryCollection) { + // Рекурсивная обработка коллекции + val validGeoms = (0 until validGeom.numGeometries) + .map { validateAndNormalize(validGeom.getGeometryN(it)) } + .filter { !it.isEmpty && it.area > MIN_AREA } + if (validGeoms.isEmpty()) geometryFactory.createPolygon() + else safeUnion(validGeoms) + } else { + // Преобразуем в полигон через буфер + validGeom.buffer(SNAP_TOLERANCE * 0.1) + } + } + } + } + } + + // Вспомогательный класс для обработки слотов + private data class SlotGeometry( + val slot: SlotDTO, + val geometry: Geometry, + val intersectionArea: Double + ) + + /** + * Оптимальное покрытие полигона с балансом площади и количества слотов + * + * @param slots список доступных слотов с полигонами в формате WKT + * @param targetWkt целевой полигон в формате WKT + * @param minCoverageRatio минимальная доля покрытия (0.0 - 1.0), по умолчанию 0.95 (95%) + * @param minRelativeGain минимальный относительный прирост площади для продолжения отбора + * @return список оптимально отобранных слотов + */ + fun optimalPolygonCoverage( + slots: List, + targetWkt: String, + minCoverageRatio: Double = 0.95, + minRelativeGain: Double = 0.005 + ): List { + + val wktReader = WKTReader() + val geometryFactory = GeometryFactory() + + // Валидация целевого полигона + val targetGeom: Polygon = try { + val raw = wktReader.read(targetWkt) + require(raw is Polygon) { "Target must be Polygon, got ${raw.geometryType}" } + GeometryUtils.validateAndNormalize(raw) as Polygon + } catch (e: Exception) { + throw IllegalArgumentException("Invalid target WKT: ${e.message}", e) + } + val targetArea = targetGeom.area + require(targetArea > 1e-6) { "Target polygon has near-zero area: $targetArea" } + + + + + // Предварительная обработка слотов с защитой от ошибок + val validSlots = slots.mapNotNull { slot -> + try { + val rawGeom = wktReader.read(slot.contour) + val validGeom = GeometryUtils.validateAndNormalize(rawGeom) + + // Быстрая проверка через envelope (ограничивающий прямоугольник) + if (!validGeom.envelopeInternal.intersects(targetGeom.envelopeInternal)) { + return@mapNotNull null + } + + val intersection = GeometryUtils.safeIntersection(validGeom, targetGeom) + val area = intersection.area + + // Фильтрация аномалий: слишком маленькие или слишком большие пересечения + if (area > 1e-6 && area < targetArea * 10) { + SlotGeometry(slot, validGeom, area) + } else { + null + } + } catch (e: Exception) { + // Для production замените на логирование + println("Skipping invalid slot ${slot.slotNumber}: ${e.message?.take(100)}") + null + } + }.sortedByDescending { it.intersectionArea } + .toMutableList() + + if (validSlots.isEmpty()) return emptyList() + + + + + + // Жадный отбор с приоритетом уникальной площади + val selected = mutableListOf() + var uncovered: Geometry = targetGeom // Тип Geometry, а не Polygon! + + // забронированные слоты + val bookedSlots = validSlots.filter { it.slot.state == SlotStatus.BOOKED } + bookedSlots.forEach { bestCandidate -> + + selected.add(bestCandidate) + validSlots.remove(bestCandidate) + validSlots.removeIf { slot -> + bestCandidate.slot.satelliteId == slot.slot.satelliteId && + abs(Duration.between(slot.slot.tn, bestCandidate.slot.tn).seconds) < 30 + } + if (uncovered.area > targetArea * (1 - minCoverageRatio)){ + uncovered = GeometryUtils.safeDifference(uncovered, bestCandidate.geometry) + } + } + + + while (uncovered.area > targetArea * (1 - minCoverageRatio)) { + var bestGain = 0.0 + var bestCandidate: SlotGeometry? = null + for (candidate in validSlots) { + if (candidate in selected) continue + val newPart = GeometryUtils.safeIntersection(candidate.geometry, uncovered) + val gain = newPart.area + if (gain > bestGain) { + bestGain = gain + bestCandidate = candidate + } + } + // Условия остановки +// if (bestCandidate == null || bestGain < targetArea * minRelativeGain || bestGain < 1e-6) break + if (bestCandidate == null || bestGain < 1e-8) break + selected.add(bestCandidate) + validSlots.remove(bestCandidate) + validSlots.removeIf { slot -> + slot.slot.satelliteId == bestCandidate.slot.satelliteId && + abs(Duration.between(slot.slot.tn, bestCandidate.slot.tn).seconds) < 30 + } + uncovered = GeometryUtils.safeDifference(uncovered, bestCandidate.geometry) + // Защита от вырожденных геометрий + if (uncovered.isEmpty) break + } + + // Постоптимизация: удаление избыточных слотов + return removeRedundantSlots(selected, targetGeom, targetArea).map { it.slot } + } + + private fun removeRedundantSlots( + slots: List, + targetGeom: Polygon, + targetArea: Double + ): List { + val essential = slots.filter { it.slot.state == SlotStatus.BOOKED }.toMutableList() + val candidates = slots.filter { it.slot.state == SlotStatus.AVAILABLE }.sortedByDescending { it.intersectionArea }.toMutableList() + + while (candidates.isNotEmpty()) { + val candidate = candidates.removeFirst() + val others = essential + candidates + + val othersUnion = if (others.isNotEmpty()) { + GeometryUtils.safeUnion(others.map { it.geometry }) + } else { + GeometryFactory().createPolygon() + } + + // Вычисляем покрытие с и без кандидата + val coverageWith = GeometryUtils.safeIntersection( + GeometryUtils.safeUnion(listOf(othersUnion, candidate.geometry)), + targetGeom + ) + val coverageWithout = GeometryUtils.safeIntersection(othersUnion, targetGeom) + + val areaLoss = coverageWith.area - coverageWithout.area + + // Сохраняем только если потеря площади значима (>0.1% от целевой) + if (areaLoss > targetArea * 0.001) { + essential.add(candidate) + } + } + + return essential + } + + + + + + + + + + + + + +} \ No newline at end of file diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/TimeUtils.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/TimeUtils.kt new file mode 100644 index 0000000..4451644 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/TimeUtils.kt @@ -0,0 +1,6 @@ +package space.nstart.pcp.slots_service.model + +import java.time.LocalDateTime + +fun intersection(tn : LocalDateTime, tk : LocalDateTime, tn2 : LocalDateTime, tk2 : LocalDateTime) = + tn <= tk2 && tn2 <= tk \ No newline at end of file diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt new file mode 100644 index 0000000..764ff94 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt @@ -0,0 +1,52 @@ +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.SlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import kotlin.math.sin + +data class PreparedCoverageSlot( + val sourceSlotId: Long, + val slot: SlotDTO +) + +abstract class AbstractSatellite( + val id : Long = 0, + var cycleRevs : Long = 243, + var tnCalc : LocalDateTime = LocalDateTime.of( + LocalDate.of(2025,10,27), + LocalTime.of(0,29,23,162630000)), + var durationCalc : Long = 16, + var slotDuration : Long = 10, + val maxChainLength : Int = 30, + val maxSurveyDuration : Int = 300, + val mmi : Long = 10, + val angles : List = listOf(), + val ic : InitialConditions = InitialConditions() +) { + + abstract fun prepareCoverageSlots( + slots : List, + bookedSlots : List, + timeStart : LocalDateTime, + timeStop : LocalDateTime, + sign : RevolutionSign?, + states : List? = null + ) : List + + fun prepareSlots( + slots : List, + bookedSlots : List, + timeStart : LocalDateTime, + timeStop : LocalDateTime, + sign : RevolutionSign?, + states : List? = null + ) : List = prepareCoverageSlots(slots, bookedSlots, timeStart, timeStop, sign, states).map { it.slot } + +} 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 new file mode 100644 index 0000000..c83c350 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt @@ -0,0 +1,130 @@ +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.SlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.BookedSlot +import space.nstart.pcp.slots_service.model.intersection +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import kotlin.math.abs + +class TestSatelliteImpl( + id : Long = 0, + cycleRevs : Long = 243, + tnCalc : LocalDateTime = LocalDateTime.of( + LocalDate.of(2025,10,27), + LocalTime.of(0,29,23,162630000)), + durationCalc : Long = 16, + slotDuration : Long = 10, + maxChainLength : Int = 3, + maxSurveyDuration: Int = 300, + mmi: Long = 10, + angles : List = listOf(), + ic : InitialConditions = InitialConditions() +) : AbstractSatellite(id, cycleRevs, tnCalc, durationCalc, slotDuration, maxChainLength, maxSurveyDuration, mmi, angles, ic) { + override fun prepareCoverageSlots( + slots: List, + bookedSlots: List, + timeStart : LocalDateTime, + timeStop : LocalDateTime, + sign : RevolutionSign?, + states : List? + ): List { + class BookedInfo(val id : Long, val cycle : Long) + 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() + ) + calculateChainLengths(bookedData) + for (cycleN in cycleBegin..cycleEnd) { + val currentSlots = slots.map { ent -> + SlotEntity( + ent.slotId, + ent.slotNum, + cycleN, + ent.satelliteId, + ent.coveringType, + ent.tn.plusDays(cycleN * durationCalc), + ent.tk.plusDays(cycleN * durationCalc), + ent.roll, + ent.contour, + ent.revolution + cycleN * cycleRevs, + ent.revolutionSign, + latitude = ent.latitude, + longitude = ent.longitude + ) + } + selectedSlots.addAll( + currentSlots.filter { slot -> + 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 gam = abs(it.roll - booked.roll) + + if (dif) { + if (gam < 0.01 && booked.neighbours < maxChainLength) { +// if (gam < 0.01) { +// booked.neighbours++ + 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() + ) + ) + }) + } + if (!states.isNullOrEmpty()) + selectedSlots.removeIf { !states.contains(it.slot.state) } + return selectedSlots + } + + private fun calculateChainLengths(slots: MutableList) { + if (slots.isEmpty()) + return + // 1. Сортируем слоты по времени + slots.sortBy { it.t } + var chainStartIndex = 0 + // 2. Проходим по отсортированному списку и определяем границы цепочек + for (i in 1 until slots.size) { + val prev = slots[i - 1] + val current = slots[i] + // Проверяем критерии соседства: + // - разница во времени < 1 секунды + // - разница в roll < 0.01 + val timeDiffSeconds = Duration.between(prev.t, current.t).toMillis() / 1000.0 + val rollDiff = abs(current.roll - prev.roll) + // Если элементы НЕ являются соседями — завершаем текущую цепочку + if (timeDiffSeconds >= 30.0 || rollDiff >= 0.01) { + // Заполняем длину цепочки всем её элементам + val chainLength = (i - chainStartIndex).toShort() + for (j in chainStartIndex until i) { + slots[j].neighbours = chainLength + } + // Начинаем новую цепочку + chainStartIndex = i + } + } + // 3. Обрабатываем последнюю цепочку + val lastChainLength = (slots.size - chainStartIndex).toShort() + for (j in chainStartIndex until slots.size) { + slots[j].neighbours = lastChainLength + } + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedRequestRepository.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedRequestRepository.kt new file mode 100644 index 0000000..4b1d41c --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedRequestRepository.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp.slots_service.repository + +import jakarta.transaction.Transactional +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 space.nstart.pcp.slots_service.entity.BookedRequestEntity + +interface BookedRequestRepository : JpaRepository{ + + @Modifying + @Transactional + @Query("DELETE FROM booked_slot_request WHERE booked_slot_request_id =:id", nativeQuery = true) + fun deleteByBookedSlotRequestId(@Param("id") id : Long) : Int + + fun findByBookedSlot_BookedSlotIdAndRequestId(slotId:Long, requestId:String): BookedRequestEntity? + + fun findByRequestId(requestId:String): List +} \ No newline at end of file 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 new file mode 100644 index 0000000..40f1bad --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp.slots_service.repository + +import jakarta.transaction.Transactional +import org.springframework.data.jpa.repository.EntityGraph +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 space.nstart.pcp.slots_service.entity.BookedSlotEntity +import java.util.Optional + +interface BookedSlotsRepository : JpaRepository{ + + fun findByBookedSlotId(id : Long) : Optional + + fun findBySlot_SlotIdAndCycle(id : Long, cycle : Long) : BookedSlotEntity? + + fun findBySlot_SatelliteIdAndCycleIn(id : Long, n : Iterable) : List + + fun findBySlot_SatelliteIdAndCycleBetween(id: Long, cycleBegin: Long, cycleEnd: Long): List + + fun findBySlot_SatelliteIdInAndCycleBetween(ids: Collection, cycleBegin: Long, cycleEnd: Long): List + + fun countBySlot_SatelliteId(id: Long): Long + + @EntityGraph(attributePaths = ["slot"]) + fun findAllByBookedSlotIdIn(ids: Collection): List + + @EntityGraph(attributePaths = ["slot"]) + fun findAllByStatus(status: String): List + + @Modifying + @Transactional + @Query("DELETE FROM booked_slot WHERE booked_slot_id =:id", nativeQuery = true) + fun deleteByBookedSlotId(@Param("id") id : Long) : Int +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SatelliteIcRepository.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SatelliteIcRepository.kt new file mode 100644 index 0000000..ec3fc49 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SatelliteIcRepository.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.slots_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 space.nstart.pcp.slots_service.entity.SatelliteIcEntity + +interface SatelliteIcRepository : JpaRepository { + fun findAllByOrderBySatelliteIdAsc(): List + + @Modifying + @Query("delete from SatelliteIcEntity entity where entity.satelliteId = :satelliteId") + fun deleteBySatelliteId(@Param("satelliteId") satelliteId: Long): Int +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepository.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepository.kt new file mode 100644 index 0000000..34e4622 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepository.kt @@ -0,0 +1,74 @@ +package space.nstart.pcp.slots_service.repository + +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import jakarta.transaction.Transactional +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 space.nstart.pcp.slots_service.entity.SlotEntity +import java.time.LocalDateTime +import java.util.Optional + +interface SlotRepository: JpaRepository, SlotRepositoryCustom { + interface SlotCalculationSummaryProjection { + val satelliteId: Long + val slotCount: Long + val minTime: LocalDateTime? + val maxTime: LocalDateTime? + val minRevolution: Long? + val maxRevolution: Long? + } + + + @Modifying + @Transactional + @Query("DELETE FROM slot WHERE satellite_id =:sat", nativeQuery = true) + fun deleteBySatId(@Param("sat") sat : Long) : Int + + fun findBySatelliteId(id : Long) : List + + fun countBySatelliteId(id: Long): Long + + fun findBySatelliteIdAndRevolutionIn(id : Long, revs : Iterable) : List + + fun findBySatelliteIdAndSlotNum(id : Long, num : Long) : Optional + + fun findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc( + satelliteId: Long, + timeStart: java.time.LocalDateTime, + timeStop: java.time.LocalDateTime + ): List + + @Query( + """ + SELECT + CAST(:satelliteId AS BIGINT) AS "satelliteId", + CASE + WHEN EXISTS (SELECT 1 FROM slot WHERE satellite_id = :satelliteId) + THEN CAST(1 AS BIGINT) + ELSE CAST(0 AS BIGINT) + END AS "slotCount", + (SELECT tn FROM slot WHERE satellite_id = :satelliteId ORDER BY tn ASC LIMIT 1) AS "minTime", + (SELECT tk FROM slot WHERE satellite_id = :satelliteId ORDER BY tk DESC LIMIT 1) AS "maxTime", + (SELECT revolution FROM slot WHERE satellite_id = :satelliteId ORDER BY revolution ASC LIMIT 1) AS "minRevolution", + (SELECT revolution FROM slot WHERE satellite_id = :satelliteId ORDER BY revolution DESC LIMIT 1) AS "maxRevolution" + """, + nativeQuery = true + ) + fun slotCalculationSummary(@Param("satelliteId") satelliteId: Long): SlotCalculationSummaryProjection + + @Query(value = "SELECT * FROM slot WHERE ST_Intersects( ST_GeomFromText(:polygon, 4326), contour_geom)", nativeQuery = true) + fun findAllByWKT(@Param("polygon") polygonWkt: String): List + + @Query(value = "SELECT * FROM slot WHERE satellite_id=:id AND ST_Intersects( ST_GeomFromText(:polygon, 4326), contour_geom)", nativeQuery = true) + fun findByWKTAndSatId(@Param("polygon") polygonWkt: String, @Param("id") id : Long): List + + @Query( + value = "SELECT * FROM slot WHERE satellite_id IN (:ids) AND ST_Intersects(ST_GeomFromText(:polygon, 4326), contour_geom)", + nativeQuery = true + ) + fun findByWKTAndSatelliteIdIn(@Param("polygon") polygonWkt: String, @Param("ids") ids: Collection): List + +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryCustom.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryCustom.kt new file mode 100644 index 0000000..49aa02e --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryCustom.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.slots_service.repository + +import java.time.LocalDateTime + +data class BatchCoverageSearchRequest( + val targetId: Long, + val satelliteId: Long, + val windowStart: LocalDateTime, + val windowStop: LocalDateTime, + val polygonWkt: String +) + +data class BatchCoverageSlotRow( + val targetId: Long, + val slotId: Long, + val slotNum: Long, + val cycle: Long, + val satelliteId: Long, + val coveringType: Int, + val tn: java.time.LocalDateTime, + val tk: java.time.LocalDateTime, + val roll: Double, + val contour: String, + val revolution: Long, + val revolutionSign: String, + val latitude: Double, + val longitude: Double +) + +interface SlotRepositoryCustom { + fun findTargetSlotRows( + searchRequests: List, + chunkSize: Int + ): List +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt new file mode 100644 index 0000000..7b02854 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt @@ -0,0 +1,107 @@ +package space.nstart.pcp.slots_service.repository + +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.Timestamp + +@Repository +class SlotRepositoryImpl( + private val jdbcTemplate: JdbcTemplate +) : SlotRepositoryCustom { + + override fun findTargetSlotRows( + searchRequests: List, + chunkSize: Int + ): List { + if (searchRequests.isEmpty()) { + return emptyList() + } + + val effectiveChunkSize = chunkSize.coerceAtLeast(1) + + return searchRequests + .distinct() + .chunked(effectiveChunkSize) + .flatMap { chunk -> + jdbcTemplate.query( + buildBatchSpatialFetchSql(chunk.size), + { rs, _ -> + BatchCoverageSlotRow( + targetId = rs.getLong("target_id"), + slotId = rs.getLong("slot_id"), + slotNum = rs.getLong("slot_num"), + cycle = rs.getLong("cycle"), + satelliteId = rs.getLong("satellite_id"), + coveringType = rs.getInt("covering_type"), + tn = rs.getTimestamp("tn").toLocalDateTime(), + tk = rs.getTimestamp("tk").toLocalDateTime(), + roll = rs.getDouble("roll"), + contour = rs.getString("contour_wkt"), + revolution = rs.getLong("revolution"), + revolutionSign = rs.getString("revolution_sign"), + latitude = rs.getDouble("latitude"), + longitude = rs.getDouble("longitude") + ) + }, + *buildBatchSpatialFetchParams(chunk) + ) + } + } + + private fun buildBatchSpatialFetchSql(searchRequestCount: Int): String { + val valuesSql = List(searchRequestCount) { + "(?::bigint, ?::bigint, ?::timestamp, ?::timestamp, ?::text)" + }.joinToString(", ") + + return """ + with search_input(target_id, satellite_id, window_start, window_stop, polygon_wkt) as ( + values $valuesSql + ), + search as ( + select + target_id, + satellite_id, + window_start, + window_stop, + st_geomfromtext(polygon_wkt, 4326) as search_geom + from search_input + ) + select distinct + search.target_id, + slot.slot_id, + slot.slot_num, + slot.cycle, + slot.satellite_id, + slot.covering_type, + slot.tn, + slot.tk, + slot.roll, + slot.contour_wkt, + slot.revolution, + slot.revolution_sign, + slot.latitude, + slot.longitude + from search + join slot on slot.satellite_id = search.satellite_id + and slot.tn >= search.window_start + and slot.tk <= search.window_stop + and slot.contour_geom && search.search_geom + and st_intersects(search.search_geom, slot.contour_geom) + order by search.target_id, slot.satellite_id, slot.slot_id + """.trimIndent() + } + + private fun buildBatchSpatialFetchParams( + chunk: List + ): Array { + val params = ArrayList(chunk.size * 5) + chunk.forEach { request -> + params += request.targetId + params += request.satelliteId + params += Timestamp.valueOf(request.windowStart) + params += Timestamp.valueOf(request.windowStop) + params += request.polygonWkt + } + return params.toTypedArray() + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotStatusService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotStatusService.kt new file mode 100644 index 0000000..e85d6e7 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotStatusService.kt @@ -0,0 +1,106 @@ +package space.nstart.pcp.slots_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository + +@Service +class BookedSlotStatusService( + private val bookedSlotsRepository: BookedSlotsRepository +) : BookedSlotStatusUpdater { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + override fun updateBookedSlotsStatus( + bookedSlotIds: List, + targetStatus: BookedSlotStatus, + context: String + ): Int { + val ids = bookedSlotIds.distinct().filter { it > 0 } + if (ids.isEmpty()) { + return 0 + } + + val bookedSlots = bookedSlotsRepository.findAllByBookedSlotIdIn(ids) + val foundIds = bookedSlots.mapNotNull { it.bookedSlotId }.toSet() + val missingIds = ids.filterNot(foundIds::contains) + if (missingIds.isNotEmpty()) { + logger.warn( + "Booked slots status update skipped missing ids: context={}, status={}, missingIds={}", + context, + targetStatus, + missingIds + ) + } + + val changed = bookedSlots.filter { bookedSlot -> + val currentStatus = bookedSlot.currentBookedSlotStatus() ?: return@filter false + when { + currentStatus == targetStatus -> false + isAllowedTransition(currentStatus, targetStatus) -> { + bookedSlot.status = targetStatus.name + true + } + else -> { + logger.warn( + "Ignoring invalid booked slot transition: id={}, current={}, target={}, context={}", + bookedSlot.bookedSlotId, + currentStatus, + targetStatus, + context + ) + false + } + } + } + + if (changed.isNotEmpty()) { + bookedSlotsRepository.saveAll(changed) + } + + logger.info( + "Booked slots status update applied: context={}, targetStatus={}, requested={}, updated={}", + context, + targetStatus, + ids.size, + changed.size + ) + return changed.size + } + + private fun BookedSlotEntity.currentBookedSlotStatus(): BookedSlotStatus? = + when (status) { + "CANCELLED" -> BookedSlotStatus.CANCELED + "CRASHED" -> BookedSlotStatus.FAILED + "COMPLETED" -> BookedSlotStatus.FINISHED + else -> runCatching { BookedSlotStatus.valueOf(status) } + .onFailure { + logger.warn("Ignoring booked slot {} with unsupported current status {}", bookedSlotId, status) + } + .getOrNull() + } + + private fun isAllowedTransition(current: BookedSlotStatus, target: BookedSlotStatus): Boolean = when (current) { + BookedSlotStatus.BOOKED -> target == BookedSlotStatus.PROCESSED || target == BookedSlotStatus.FAILED_TIMEOUT + BookedSlotStatus.PROCESSED -> target == BookedSlotStatus.FINISHED + || target == BookedSlotStatus.CANCELED + || target == BookedSlotStatus.FAILED + || target == BookedSlotStatus.FAILED_TIMEOUT + BookedSlotStatus.FINISHED, + BookedSlotStatus.CANCELED, + BookedSlotStatus.FAILED, + BookedSlotStatus.FAILED_TIMEOUT -> false + } +} + +interface BookedSlotStatusUpdater { + fun updateBookedSlotsStatus( + bookedSlotIds: List, + targetStatus: BookedSlotStatus, + context: String + ): Int +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeResolver.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeResolver.kt new file mode 100644 index 0000000..fb37c57 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeResolver.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp.slots_service.service + +import org.springframework.stereotype.Service +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import java.time.LocalDateTime + +interface BookedSlotTimeResolver { + fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? +} + +@Service +class SatelliteBookedSlotTimeResolver( + private val slotService: SlotService +) : BookedSlotTimeResolver { + override fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? = + slotService.resolveBookedSlotStartTime(bookedSlot) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeoutScheduler.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeoutScheduler.kt new file mode 100644 index 0000000..e1a07b7 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeoutScheduler.kt @@ -0,0 +1,50 @@ +package space.nstart.pcp.slots_service.service + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.slots_service.configuration.BookedSlotsTimeoutProperties +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import java.time.Clock +import java.time.LocalDateTime + +@Component +class BookedSlotTimeoutScheduler( + private val bookedSlotsRepository: BookedSlotsRepository, + private val bookedSlotStatusService: BookedSlotStatusUpdater, + private val bookedSlotTimeResolver: BookedSlotTimeResolver, + private val properties: BookedSlotsTimeoutProperties, + private val clock: Clock = Clock.systemDefaultZone() +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Scheduled(fixedDelayString = "#{@bookedSlotsTimeoutProperties.timeoutCheckInterval.toMillis()}") + fun markTimedOutBookedSlots() { + val thresholdTime = LocalDateTime.now(clock).minus(properties.timeoutThreshold) + val staleBookedSlotIds = bookedSlotsRepository.findAllByStatus(BookedSlotStatus.BOOKED.name) + .filter { bookedSlot -> + val actualStart = bookedSlotTimeResolver.resolveBookedSlotStartTime(bookedSlot) ?: return@filter false + actualStart.isBefore(thresholdTime) + } + .mapNotNull { it.bookedSlotId } + + if (staleBookedSlotIds.isEmpty()) { + logger.debug("Booked slot timeout scheduler found no stale slots") + return + } + + val updated = bookedSlotStatusService.updateBookedSlotsStatus( + bookedSlotIds = staleBookedSlotIds, + targetStatus = BookedSlotStatus.FAILED_TIMEOUT, + context = "timeout-scheduler" + ) + logger.info( + "Booked slot timeout scheduler marked stale slots: found={}, updated={}, thresholdTime={}", + staleBookedSlotIds.size, + updated, + thresholdTime + ) + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotsKafkaListener.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotsKafkaListener.kt new file mode 100644 index 0000000..d62634f --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BookedSlotsKafkaListener.kt @@ -0,0 +1,51 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class BookedSlotsKafkaListener( + objectMapperProvider: ObjectProvider, + private val bookedSlotStatusService: BookedSlotStatusUpdater +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + topics = ["\${app.kafka.topics.booked-slots}"], + groupId = "\${spring.kafka.consumer.group-id}-booked-slots" + ) + fun consume(message: String) { + try { + val payload = objectMapper.readTree(message) + val eventType = payload.path("type").asText() + if (eventType != PcpKafkaEvent.BookedSlotsStatusChangedEvent.name) { + logger.debug("Ignoring Kafka message with unsupported type {}", eventType) + return + } + + val event = readEvent(payload) + bookedSlotStatusService.updateBookedSlotsStatus( + bookedSlotIds = event.bookedSlotIds, + targetStatus = event.status, + context = "kafka:${event.sourceService}:${event.missionId ?: "-"}:${event.modeId ?: "-"}" + ) + } catch (exception: Exception) { + logger.error("Failed to process booked slots Kafka event", exception) + throw exception + } + } + + private fun readEvent(payload: JsonNode): BookedSlotsStatusChangedEvent = + objectMapper.treeToValue(payload.path("data"), BookedSlotsStatusChangedEvent::class.java) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt new file mode 100644 index 0000000..afa5901 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt @@ -0,0 +1,218 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +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.Flux +import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO +import java.util.UUID + + +@Service +/** + * Request/grid reader backed by pcp-request-service. + */ +class EarthService( + webClientBuilderProvider: ObjectProvider, + @param:Value("\${settings.request-service:\${settings.earth-grid-service:pcp-request-service}}") + private val url: String = "pcp-request-service", +) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + fun cells(importance: Double): Iterable { + val firstPage = cellsPage(importance, page = 0) + val items = firstPage.items.toMutableList() + + for (page in 1 until firstPage.totalPages) { + items += cellsPage(importance, page).items + } + + return items.map { cell -> cell.toEarthCellDto() } + } + + private fun cellsPage(importance: Double, page: Int): CellsListResponseDto = + webClientBuilder.build() + .get() + .uri(cellsUri(importance, page)) + .retrieve() + .bodyToMono(CellsListResponseDto::class.java) + .block() ?: CellsListResponseDto() + + private fun cellsUri(importance: Double, page: Int): String = + // TODO: restore countLat/countLong forwarding if pcp-request-service adds aggregation to GET /v1/cells. + "$url/v1/cells?minImportance=$importance&page=$page&size=$CELLS_PAGE_SIZE" + + fun reqcells(id : String): RequestWithCellsDTO? = + webClientBuilder.build() + .get() + .uri("$url/v1/requests/$id/with-cells") + .retrieve() + .bodyToMono(RequestWithCellsResponseDto::class.java) + .block() + ?.toRequestWithCellsDto() + + fun reqs(): Flux { + val firstPage = requestsPage(page = 0) + val summaries = firstPage.items.toMutableList() + + for (page in 1 until firstPage.totalPages) { + summaries += requestsPage(page).items + } + + val requests = summaries.map { summary -> requestDetails(summary.id).toRequestDto() } + return Flux.fromIterable(requests) + } + + private fun requestsPage(page: Int): RequestsListResponseDto = + webClientBuilder.build() + .get() + .uri("$url/v1/requests?page=$page&size=$REQUESTS_PAGE_SIZE") + .retrieve() + .bodyToMono(RequestsListResponseDto::class.java) + .block() ?: RequestsListResponseDto() + + private fun requestDetails(id: UUID): RequestDetailsResponseDto = + webClientBuilder.build() + .get() + .uri("$url/v1/requests/$id") + .retrieve() + .bodyToMono(RequestDetailsResponseDto::class.java) + .block() ?: RequestDetailsResponseDto(id = id) + + private fun CellSummaryResponseDto.toEarthCellDto(): EarthCellDTO = + EarthCellDTO( + id = cellNum, + num = cellNum, + latitude = latitude, + longitude = longitude, + importance = importance, + contour = contour, + ) + + private fun RequestDetailsResponseDto.toRequestDto(): RequestDTO = + RequestDTO( + requestId = id, + name = name, + importance = importance, + contour = geometry, + ) + + private fun RequestWithCellsResponseDto.toRequestWithCellsDto(): RequestWithCellsDTO = + RequestWithCellsDTO( + request = request.toRequestDto(), + cells = cells.map { cell -> cell.toEarthCellDto() }, + ) + + private fun RequestResponseDto.toRequestDto(): RequestDTO = + RequestDTO( + requestId = id, + name = name, + importance = importance, + contour = geometry, + ) + + private fun RequestCellResponseDto.toEarthCellDto(): EarthCellDTO = + EarthCellDTO( + id = cellNum, + num = cellNum, + importance = importance, + contour = contour, + ) + + private companion object { + const val CELLS_PAGE_SIZE = 500 + const val REQUESTS_PAGE_SIZE = 500 + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestsListResponseDto( + val items: List = emptyList(), + val page: Int = 0, + val size: Int = 0, + val totalItems: Long = 0, + val totalPages: Int = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestSummaryResponseDto( + val id: UUID, + val name: String = "", + val status: String = "", + val surveyType: String = "", + val importance: Double = 0.0, + val beginDateTime: String = "", + val endDateTime: String = "", + val kpp: List = emptyList(), + val highPriorityTransmit: Boolean = false, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestDetailsResponseDto( + val id: UUID, + val name: String = "", + val geometry: String = "", + val importance: Double = 0.0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestWithCellsResponseDto( + val request: RequestResponseDto = RequestResponseDto(), + val cells: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestResponseDto( + val id: UUID = UUID(0L, 0L), + val name: String = "", + val status: String = "", + val surveyType: String = "", + val geometry: String = "", + val importance: Double = 0.0, + val beginDateTime: String = "", + val endDateTime: String = "", + val kpp: List = emptyList(), + val highPriorityTransmit: Boolean = false, + val optics: Any? = null, + val rsa: Any? = null, + val coverage: CoverageStateDto = CoverageStateDto(), + val createdAt: String = "", + val updatedAt: String = "", + val deletedAt: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RequestCellResponseDto( + val cellNum: Long = 0, + val coveragePercent: Double = 0.0, + val importance: Double = 0.0, + val contour: String = "", +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CoverageStateDto( + val requiredPercent: Double? = null, + val currentPercent: Double = 0.0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CellsListResponseDto( + val items: List = emptyList(), + val page: Int = 0, + val size: Int = 0, + val totalItems: Long = 0, + val totalPages: Int = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CellSummaryResponseDto( + val cellNum: Long = 0, + val latitude: Double = 0.0, + val longitude: Double = 0.0, + val importance: Double = 0.0, + val contour: String = "", +) 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 new file mode 100644 index 0000000..0ef5518 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt @@ -0,0 +1,105 @@ +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.slots_service.model.satellite.AbstractSatellite +import java.time.Duration +import kotlin.math.abs + +internal object OccupiedIntervalPrefilter { + + private const val ROLL_EPSILON = 0.01 + + fun normalizeBySatellite( + occupiedIntervals: List, + satellitesById: Map + ): Map> = + occupiedIntervals + .groupBy { it.satelliteId } + .mapValues { (satelliteId, intervals) -> + val satellite = satellitesById[satelliteId] ?: return@mapValues emptyList() + normalize(intervals, satellite) + } + .filterValues { it.isNotEmpty() } + + fun normalize( + occupiedIntervals: List, + satellite: AbstractSatellite + ): List { + if (occupiedIntervals.isEmpty()) { + return emptyList() + } + + val normalized = mutableListOf() + occupiedIntervals + .sortedWith(compareBy { it.roll }.thenBy { it.startTime }.thenBy { it.endTime }) + .forEach { interval -> + val current = normalized.lastOrNull() + if (current != null && + sameRoll(current.roll, interval.roll) && + interval.startTime <= current.endTime.plusSeconds(satellite.mmi) + ) { + normalized[normalized.lastIndex] = current.copy( + startTime = minOf(current.startTime, interval.startTime), + endTime = maxOf(current.endTime, interval.endTime), + source = current.source ?: interval.source, + modeId = current.modeId ?: interval.modeId + ) + } else { + normalized += interval + } + } + return normalized + } + + fun filterCandidates( + candidateSlots: List, + occupiedIntervals: List, + satellite: AbstractSatellite + ): List { + if (candidateSlots.isEmpty() || occupiedIntervals.isEmpty()) { + return candidateSlots + } + + return candidateSlots.filter { candidate -> + isAllowed(candidate, occupiedIntervals, satellite) + } + } + + private fun isAllowed( + candidate: SlotDTO, + occupiedIntervals: List, + satellite: AbstractSatellite + ): Boolean { + val conflictingDifferentRoll = occupiedIntervals.any { occupied -> + !sameRoll(candidate.roll, occupied.roll) && + intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi) + } + if (conflictingDifferentRoll) { + return false + } + + val sameRollChains = occupiedIntervals.filter { occupied -> + sameRoll(candidate.roll, occupied.roll) && + intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi) + } + if (sameRollChains.isEmpty()) { + return true + } + + val mergedStart = sameRollChains.minOf { it.startTime }.coerceAtMost(candidate.tn) + val mergedEnd = sameRollChains.maxOf { it.endTime }.coerceAtLeast(candidate.tk) + val mergedDurationSeconds = Duration.between(mergedStart, mergedEnd).seconds + return mergedDurationSeconds <= satellite.maxSurveyDuration.toLong() + } + + private fun intersectsWithMmi( + start1: java.time.LocalDateTime, + end1: java.time.LocalDateTime, + start2: java.time.LocalDateTime, + end2: java.time.LocalDateTime, + mmi: Long + ): Boolean = start1 <= end2.plusSeconds(mmi) && end1 >= start2.minusSeconds(mmi) + + 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/SatelliteCatalogClient.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogClient.kt new file mode 100644 index 0000000..a5e6d45 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogClient.kt @@ -0,0 +1,154 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatusCode +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl + +interface SatelliteCatalogClient { + fun allSatellites(): List + fun satellites(ids: List): List + fun satellite(id: Long): AbstractSatellite + fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO +} + +@Service +class SatelliteCatalogWebClient( + webClientBuilderProvider: ObjectProvider +) : SatelliteCatalogClient { + + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val objectMapper = ObjectMapper().findAndRegisterModules() + + @Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}") + private val satelliteCatalogServiceUrl = "" + + override fun allSatellites(): List { + val summaries = webClientBuilder.build() + .get() + .uri("$satelliteCatalogServiceUrl/api/satellites") + .retrieve() + .onStatus(HttpStatusCode::isError, ::mapError) + .bodyToFlux(SatelliteSummaryDTO::class.java) + .collectList() + .block() + .orEmpty() + if (summaries.isEmpty()) { + return emptyList() + } + return fetchSatellites(summaries.map { it.id }) + } + + override fun satellites(ids: List): List { + val uniqueIds = ids.distinct() + if (uniqueIds.isEmpty()) { + return emptyList() + } + + val byId = fetchSatellites(uniqueIds).associateBy { it.id } + return uniqueIds.map { satelliteId -> + byId[satelliteId] ?: throw CustomValidationException("КА $satelliteId не зарегистрирован") + } + } + + override fun satellite(id: Long): AbstractSatellite = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogServiceUrl/api/satellites/$id") + .retrieve() + .onStatus(HttpStatusCode::isError, ::mapError) + .bodyToMono(SatelliteDTO::class.java) + .map { it.toAbstractSatellite() } + .block() + ?: throw CustomErrorException("Не удалось получить параметры КА $id: пустой ответ") + + override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = + webClientBuilder.build() + .put() + .uri("$satelliteCatalogServiceUrl/api/satellites/$id/slot-profile") + .bodyValue(request) + .retrieve() + .onStatus(HttpStatusCode::isError, ::mapError) + .bodyToMono(SatelliteSlotProfileDTO::class.java) + .block() + ?: throw CustomErrorException("Не удалось обновить slot-profile КА $id: пустой ответ") + + private fun fetchSatellites(ids: List): List = + webClientBuilder.build() + .post() + .uri("$satelliteCatalogServiceUrl/api/satellites/batch") + .bodyValue(SatelliteBatchRequestDTO(ids = ids)) + .retrieve() + .onStatus(HttpStatusCode::isError, ::mapError) + .bodyToFlux(SatelliteDTO::class.java) + .map { it.toAbstractSatellite() } + .collectList() + .block() + .orEmpty() + + private fun SatelliteDTO.toAbstractSatellite(): AbstractSatellite { + val observationProfileValue = observationProfile + ?: throw CustomErrorException("Для КА $id в каталоге не заполнен observation-profile") + val slotProfileValue = slotProfile + ?: throw CustomErrorException("Для КА $id в каталоге не заполнен slot-profile") + val tnCalcValue = slotProfileValue.tnCalc + ?: throw CustomErrorException("Для КА $id в каталоге не заполнен slot-profile.tnCalc") + + return TestSatelliteImpl( + id = id, + cycleRevs = slotProfileValue.cycleRevs, + tnCalc = tnCalcValue, + durationCalc = slotProfileValue.durationCalcDays, + slotDuration = slotProfileValue.slotDuration, + maxChainLength = slotProfileValue.maxChainLength, + maxSurveyDuration = observationProfileValue.durationMaxSeconds.toInt(), + mmi = observationProfileValue.mmiSeconds, + angles = slotProfileValue.defaultAngles.flattenAngles() + ) + } + + private fun List.flattenAngles(): List = + flatMap { listOf(it.angleBegin, it.angleEnd) } + + private fun mapError(response: ClientResponse): Mono = + response.bodyToMono(String::class.java) + .defaultIfEmpty(response.statusCode().toString()) + .flatMap { body -> + val message = extractErrorMessage(body) + val exception = if (response.statusCode().is4xxClientError) { + CustomValidationException(message) + } else { + CustomErrorException(message) + } + Mono.error(exception) + } + + private fun extractErrorMessage(body: String): String { + val text = body.trim() + if (text.isEmpty()) { + return "Ошибка обращения к каталогу спутников" + } + + return runCatching { + val node = objectMapper.readTree(text) + when { + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("message") -> node["message"].asText() + else -> text + } + }.getOrDefault(text) + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedKafkaListener.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedKafkaListener.kt new file mode 100644 index 0000000..e6260df --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedKafkaListener.kt @@ -0,0 +1,74 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent + +private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-slots-service-satellite-deleted" + +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class SatelliteDeletedKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteDeletedService: SatelliteDeletedService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + id = "satelliteDeletedFilter", + topics = ["\${app.kafka.topics.satellites:pcp.satellites}"], + groupId = SATELLITE_DELETED_CONSUMER_GROUP, + filter = "satelliteDeletedFilter" + ) + fun consume(message: String) { + try { + val kafkaMessage = objectMapper.readValue( + message, + object : TypeReference>() {} + ) + if (kafkaMessage.type != PcpKafkaEvent.SatelliteDeletedEvent) { + logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type) + return + } + + val event = kafkaMessage.data + logger.info( + "Received SatelliteDeletedEvent in slots-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + event.deleteIdentifiers().forEach { satelliteId -> + satelliteDeletedService.deleteSatelliteData(satelliteId) + } + logger.info( + "SatelliteDeletedEvent processed in slots-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + } catch (exception: Exception) { + logger.error("Failed to process SatelliteDeletedEvent in slots-service", exception) + throw exception + } + } + + private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List = + listOfNotNull(satelliteId, noradId).distinct() +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedService.kt new file mode 100644 index 0000000..a66be6f --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedService.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp.slots_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import space.nstart.pcp.slots_service.repository.SatelliteIcRepository +import space.nstart.pcp.slots_service.repository.SlotRepository + +@Service +class SatelliteDeletedService( + private val slotRepository: SlotRepository, + private val bookedSlotsRepository: BookedSlotsRepository, + private val satelliteIcRepository: SatelliteIcRepository +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun deleteSatelliteData(satelliteId: Long): SatelliteSlotsDeletionSummary { + val bookedSlotsDeletedByCascade = bookedSlotsRepository.countBySlot_SatelliteId(satelliteId) + val slotsBeforeDelete = slotRepository.countBySatelliteId(satelliteId) + val slotsDeleted = slotRepository.deleteBySatId(satelliteId) + val initialConditionsDeleted = satelliteIcRepository.deleteBySatelliteId(satelliteId) + val summary = SatelliteSlotsDeletionSummary( + satelliteId = satelliteId, + slotsDeleted = slotsDeleted, + bookedSlotsDeletedByCascade = bookedSlotsDeletedByCascade, + initialConditionsDeleted = initialConditionsDeleted + ) + logger.info( + "Deleted slots data for satellite: satelliteId={}, slotsDeleted={}, slotsBeforeDelete={}, bookedSlotsDeletedByCascade={}, initialConditionsDeleted={}", + summary.satelliteId, + summary.slotsDeleted, + slotsBeforeDelete, + summary.bookedSlotsDeletedByCascade, + summary.initialConditionsDeleted + ) + return summary + } +} + +data class SatelliteSlotsDeletionSummary( + val satelliteId: Long, + val slotsDeleted: Int, + val bookedSlotsDeletedByCascade: Long, + val initialConditionsDeleted: Int +) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcKafkaPublisher.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcKafkaPublisher.kt new file mode 100644 index 0000000..08c069b --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcKafkaPublisher.kt @@ -0,0 +1,58 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import java.nio.charset.StandardCharsets + +@Component +class SatelliteIcKafkaPublisher( + kafkaTemplateProvider: ObjectProvider>, + objectMapperProvider: ObjectProvider, + @param:Value("\${spring.kafka.template.default-topic:}") private val topic: String, + @param:Value("\${spring.application.name:slots-service}") private val applicationName: String +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val kafkaTemplate = kafkaTemplateProvider.ifAvailable + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + fun publish(messagePayload: SatelliteICDTO) { + val template = kafkaTemplate ?: throw CustomErrorException("Kafka publisher for satellite IC is unavailable") + if (topic.isBlank()) { + throw CustomErrorException("Kafka topic for satellite IC publishing is not configured") + } + + val message = KafkaMessage( + type = PcpKafkaEvent.ICRVPlacedEvent, + data = messagePayload + ).apply { + source = applicationName + } + + val payload = try { + objectMapper.writeValueAsString(message) + } catch (exception: JsonProcessingException) { + throw CustomErrorException("Ошибка сериализации начальных условий КА ${messagePayload.satelliteId}: ${exception.message}") + } + + val record = ProducerRecord(topic, messagePayload.satelliteId.toString(), payload) + record.headers().add(TYPE_HEADER, PcpKafkaEvent.ICRVPlacedEvent.name.toByteArray(StandardCharsets.UTF_8)) + template.send(record) + logger.info("Published satellite initial conditions event: satelliteId={}, topic={}, eventType={}", messagePayload.satelliteId, topic, PcpKafkaEvent.ICRVPlacedEvent) + } + + companion object { + private const val TYPE_HEADER = "type" + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcService.kt new file mode 100644 index 0000000..26452e7 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcService.kt @@ -0,0 +1,116 @@ +package space.nstart.pcp.slots_service.service + +import ballistics.types.InitialConditions +import ballistics.types.OrbitalPoint +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.entity.SatelliteIcEntity +import space.nstart.pcp.slots_service.repository.SatelliteIcRepository + +@Service +class SatelliteIcService( + private val satelliteIcRepository: SatelliteIcRepository, + private val satelliteCatalogClient: SatelliteCatalogClient +) { + + @Transactional(readOnly = true) + fun all(): List = + satelliteIcRepository.findAllByOrderBySatelliteIdAsc().map { it.toDto() } + + @Transactional(readOnly = true) + fun bySatelliteId(satelliteId: Long): SatelliteICDTO = + requireSatelliteIc(satelliteId).toDto() + + @Transactional(readOnly = true) + fun initialConditions(satelliteId: Long): InitialConditions = + requireSatelliteIc(satelliteId).toModel() + + @Transactional + fun create(request: SatelliteICDTO): SatelliteICDTO { + requireSatelliteExists(request.satelliteId) + if (satelliteIcRepository.existsById(request.satelliteId)) { + throw CustomValidationException("Начальные условия КА ${request.satelliteId} уже зарегистрированы") + } + return satelliteIcRepository.save(request.toEntity()).toDto() + } + + @Transactional + fun update(satelliteId: Long, request: InitialConditionsDTO): SatelliteICDTO { + requireSatelliteExists(satelliteId) + if (!satelliteIcRepository.existsById(satelliteId)) { + throw CustomValidationException("Начальные условия КА $satelliteId не зарегистрированы") + } + return satelliteIcRepository.save(request.toEntity(satelliteId)).toDto() + } + + @Transactional + fun delete(satelliteId: Long) { + if (!satelliteIcRepository.existsById(satelliteId)) { + throw CustomValidationException("Начальные условия КА $satelliteId не зарегистрированы") + } + satelliteIcRepository.deleteById(satelliteId) + } + + private fun requireSatelliteIc(satelliteId: Long): SatelliteIcEntity = + satelliteIcRepository.findById(satelliteId).orElseThrow { + CustomValidationException("Начальные условия КА $satelliteId не зарегистрированы") + } + + private fun requireSatelliteExists(satelliteId: Long) { + satelliteCatalogClient.satellite(satelliteId) + } + + private fun SatelliteIcEntity.toDto(): SatelliteICDTO = + SatelliteICDTO( + satelliteId = satelliteId, + ic = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = orbTime, + revolution = orbRevolution, + vx = vx, + vy = vy, + vz = vz, + x = x, + y = y, + z = z + ), + sBall = sBall, + f81 = f81 + ) + ) + + private fun SatelliteIcEntity.toModel(): InitialConditions = + InitialConditions( + point = OrbitalPoint( + fromDateTime(orbTime), + orbRevolution.toInt(), + Vector3D(x, y, z), + Vector3D(vx, vy, vz) + ), + sBall, + f81 + ) + + private fun SatelliteICDTO.toEntity(): SatelliteIcEntity = ic.toEntity(satelliteId) + + private fun InitialConditionsDTO.toEntity(satelliteId: Long): SatelliteIcEntity = + SatelliteIcEntity( + satelliteId = satelliteId, + orbTime = orbPoint.time, + orbRevolution = orbPoint.revolution, + x = orbPoint.x, + y = orbPoint.y, + z = orbPoint.z, + vx = orbPoint.vx, + vy = orbPoint.vy, + vz = orbPoint.vz, + sBall = sBall, + f81 = f81 + ) +} 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 new file mode 100644 index 0000000..9edcbd5 --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -0,0 +1,2497 @@ +package space.nstart.pcp.slots_service.service + +import ballistics.Ballistics +import ballistics.flightLine.PointOnEarthCalculator +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.orbitalPoints.timeStepper.RungeStepper +import ballistics.types.* +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.earth.EarthPZ90d02 +import ballistics.utils.fromDateTime +import ballistics.utils.math.Vector3D +import ballistics.utils.toDateTime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import org.locationtech.jts.io.WKTWriter +import org.postgresql.copy.CopyManager +import org.postgresql.core.BaseConnection +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import space.nstart.pcp.pcp_types_lib.dto.ballistics.DurationOnRevDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.IntervalDTO +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.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.requests.slots.* +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.entity.BookedRequestEntity +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.ContinuousReqCoverSolver +import space.nstart.pcp.slots_service.model.Mar +import space.nstart.pcp.slots_service.model.OptimalReqCoverSolver +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot +import space.nstart.pcp.slots_service.repository.BatchCoverageSearchRequest +import space.nstart.pcp.slots_service.repository.BookedRequestRepository +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import space.nstart.pcp.slots_service.repository.SlotRepository +import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry +import java.io.StringReader +import java.time.Duration +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.sql.DataSource +import kotlin.jvm.optionals.getOrNull +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.roundToLong +import kotlin.system.measureTimeMillis + + +@Service +class SlotService { + private data class CoverageSelectionResult( + val slots: List, + val sourceSlotIds: Int, + val preparedSlotsBeforeOccupiedFilter: Int, + val occupiedFilteredSlots: Int, + val candidateSlotsBeforeSunFilter: Int, + val sunFilteredSlots: Int, + val solverInputSlots: Int, + val solverOutputSlots: Int, + val candidateSelectionDurationMs: Long, + val solverDurationMs: Long, + val totalDurationMs: Long + ) + + private data class SunFilterResult( + val slots: List, + val filteredCount: Int + ) + + private data class CoverageTargetContext( + val targetId: Long, + val normalizedWkt: String, + val searchWkts: List + ) + + private data class SatelliteCycleWindow( + val satellite: AbstractSatellite, + val cycleBegin: Long, + val cycleEnd: Long + ) + + private data class SatelliteBaseWindow( + val satelliteId: Long, + val windowStart: LocalDateTime, + val windowStop: LocalDateTime + ) + + private data class CoverageBatchContext( + val selectedSatellites: List, + val selectedSatellitesById: Map, + val bookedSlotsBySatelliteId: Map>, + val rawSlotIdsByTargetIdAndSatelliteId: Map>>, + val preparedSlotsBySatelliteIdAndSourceSlotId: Map>>, + val occupiedIntervalsBySatelliteId: Map>, + val totalUniqueRawSlots: Int, + val totalPreparedSlots: Int + ) + + private data class IndexedCoverageBatchResult( + val index: Int, + val response: SlotCoverageBatchResponseDTO, + val sourceSlotIds: Int, + val preparedSlotsBeforeOccupiedFilter: Int, + val occupiedFilteredSlots: Int, + val candidateSlotsBeforeSunFilter: Int, + val sunFilteredSlots: Int, + val solverInputSlots: Int, + val solverOutputSlots: Int, + val candidateSelectionDurationMs: Long, + val solverDurationMs: Long, + val totalDurationMs: Long + ) + + private data class PreparedSlotsPrecomputeResult( + val preparedSlotsBySatelliteIdAndSourceSlotId: Map>>, + val totalPreparedSlots: Int + ) + + private data class SlotCalculationChunk( + val index: Int, + val durationSeconds: Long, + val generationStart: LocalDateTime, + val generationEndExclusive: LocalDateTime, + val generationStepSeconds: Long, + val sourceWindowStartSeconds: Double, + val sourceWindowEndSeconds: Double + ) + + private data class PreparedOrbitalWindow( + val start: LocalDateTime, + val endInclusive: LocalDateTime, + val points: List + ) + + private sealed interface SlotCalculationChunkMessage { + val index: Int + } + + private data class SlotCalculationChunkBatch( + override val index: Int, + val slots: List + ) : SlotCalculationChunkMessage + + private data class SlotCalculationChunkComplete( + override val index: Int, + val durationSeconds: Long, + val generationStart: LocalDateTime, + val generationEndExclusive: LocalDateTime, + val slotCount: Int, + val batchCount: Int, + val computeDurationMs: Long, + val orbitalWindowPrepareDurationMs: Long, + val contourBuildDurationMs: Long, + val orbitalWindowCount: Int, + val contourCalculationCount: Int + ) : SlotCalculationChunkMessage + + private data class SlotCalculationPendingChunk( + val batches: MutableList> = mutableListOf(), + var completion: SlotCalculationChunkComplete? = null + ) + + private data class SlotCalculationWriteSummary( + val totalSlots: Int, + val flushCount: Int, + val writeDurationMs: Long, + val totalComputeDurationMs: Long, + val totalOrbitalWindowPrepareDurationMs: Long, + val totalContourBuildDurationMs: Long, + val orbitalWindowCount: Int, + val contourCalculationCount: Int, + val streamedBatchCount: Int, + val maxPendingBatches: Int, + val heaviestChunks: List + ) + + companion object { + private const val GEOMETRY_INTERSECTION_AREA_EPSILON = 1e-8 + private const val GEOMETRY_DISTANCE_EPSILON = 1e-6 + private const val MERGE_MAX_TIME_GAP_SECONDS = 1L + private const val ROLL_EPSILON = 0.01 + private const val DEFAULT_BATCH_PARALLELISM = 4 + private const val DEFAULT_BATCH_SPATIAL_FETCH_CHUNK_SIZE = 256 + private const val DEFAULT_SLOT_CALC_PARALLELISM = 4 + private const val DEFAULT_SLOT_CALC_CHUNK_DURATION_SECONDS = 86_400L + private const val DEFAULT_SLOT_CALC_FLUSH_THRESHOLD = 20_000 + private const val DEFAULT_SLOT_CALC_STREAM_BATCH_SIZE = 2_000 + private const val DEFAULT_SLOT_CALC_SOURCE_MARGIN_SECONDS = 120L + private const val CONTOUR_SAMPLE_STEP_SECONDS = 5L + } + + private val slotCalculationDispatcher by lazy { + Dispatchers.Default.limitedParallelism(slotCalcParallelism.coerceAtLeast(1)) + } + private val astronomer = AstronomerJ2000(EarthType.PZ90d02) + + private val logger: Logger = LoggerFactory.getLogger(this::class.java) + + + @Autowired + private lateinit var dataSource: DataSource + + @Value("\${pcp.slot-service.batch.parallelism:$DEFAULT_BATCH_PARALLELISM}") + private var batchParallelism: Int = DEFAULT_BATCH_PARALLELISM + + @Value("\${pcp.slot-service.batch.spatial-fetch-chunk-size:$DEFAULT_BATCH_SPATIAL_FETCH_CHUNK_SIZE}") + private var batchSpatialFetchChunkSize: Int = DEFAULT_BATCH_SPATIAL_FETCH_CHUNK_SIZE + + @Value("\${pcp.slot-service.calc.parallelism:$DEFAULT_SLOT_CALC_PARALLELISM}") + private var slotCalcParallelism: Int = DEFAULT_SLOT_CALC_PARALLELISM + + @Value("\${pcp.slot-service.calc.chunk-duration-seconds:$DEFAULT_SLOT_CALC_CHUNK_DURATION_SECONDS}") + private var slotCalcChunkDurationSeconds: Long = DEFAULT_SLOT_CALC_CHUNK_DURATION_SECONDS + + @Value("\${pcp.slot-service.calc.flush-threshold:$DEFAULT_SLOT_CALC_FLUSH_THRESHOLD}") + private var slotCalcFlushThreshold: Int = DEFAULT_SLOT_CALC_FLUSH_THRESHOLD + + @Value("\${pcp.slot-service.calc.stream-batch-size:$DEFAULT_SLOT_CALC_STREAM_BATCH_SIZE}") + private var slotCalcStreamBatchSize: Int = DEFAULT_SLOT_CALC_STREAM_BATCH_SIZE + + @Value("\${pcp.slot-service.calc.source-margin-seconds:$DEFAULT_SLOT_CALC_SOURCE_MARGIN_SECONDS}") + private var slotCalcSourceMarginSeconds: Long = DEFAULT_SLOT_CALC_SOURCE_MARGIN_SECONDS + + private val copyTimestampFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS") + + + @Autowired + private lateinit var slotRepository: SlotRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + private lateinit var requestRepository: BookedRequestRepository + + @Autowired + private lateinit var bookedSlotsRepository: BookedSlotsRepository + + @Autowired + private lateinit var bookedSlotStatusService: BookedSlotStatusUpdater + + @Autowired + private lateinit var earthService: EarthService + + @Autowired + private lateinit var satelliteCatalogClient: SatelliteCatalogClient + + @Autowired + private lateinit var satelliteIcService: SatelliteIcService + + @Autowired + private lateinit var satelliteIcKafkaPublisher: SatelliteIcKafkaPublisher + + + + fun rvaCommon(id : Long, duration : Long, stations : List): List{ + logger.info("Расчет ЗРВ для КА $id на $duration суток для ${stations.size} станций") + val ic = initialConditionsBySatelliteId(id) + val bal = Ballistics() + bal.modDVType = ModDVType.KONDOR_PROGNOZ + var r = bal.calculateOrbPoints(ic, ic.point.t, ic.point.t + duration * 86400) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r") + + val ppis = stations.map { station -> + PPI( + station.number, 1, station.position.lat * PI / 180, station.position.long * PI / 180, + station.position.height, station.elevationMin * PI / 180, station.elevationMax * PI / 180 + ) + } + r = bal.calculateZRV( + ppis,ic.point.t, ic.point.t + duration * 86400 + ) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета ЗРВ: $r") + return bal.zrv.map { mkRVA(it, id) }.sortedBy { it.onStart.time } + } + + + + fun rvaMerge(id : Long, duration : Long, stations : List): List{ + val rva = rvaCommon(id, duration, stations) + val intervals = mutableListOf() + + if (rva.isEmpty()) { + return intervals + } + + var currentBegin = rva.first().onStart.time + var currentEnd = rva.first().onStop.time + + for (index in 1 until rva.size) { + val next = rva[index] + val nextBegin = next.onStart.time + val nextEnd = next.onStop.time + + if (nextBegin <= currentEnd) { + if (nextEnd > currentEnd) { + currentEnd = nextEnd + } + continue + } + + intervals.add( + IntervalDTO( + begin = currentBegin, + end = currentEnd, + durationSec = Duration.between(currentBegin, currentEnd).toMillis() / 1000.0 + ) + ) + currentBegin = nextBegin + currentEnd = nextEnd + } + + intervals.add( + IntervalDTO( + begin = currentBegin, + end = currentEnd, + durationSec = Duration.between(currentBegin, currentEnd).toMillis() / 1000.0 + ) + ) + + return intervals + } + + + fun rvaMergeOnRev(id : Long, duration : Long, stations : List): List{ + val rva = rvaCommon(id, duration, stations) + val intervals = mutableListOf() + + if (rva.isEmpty()) { + return intervals + } + + rva.groupBy { it.revolution } + .toSortedMap() + .forEach { (revolution, items) -> + val sortedItems = items.sortedBy { it.onStart.time } + var currentBegin = sortedItems.first().onStart.time + var currentEnd = sortedItems.first().onStop.time + var mergedDurationSec = 0.0 + + for (index in 1 until sortedItems.size) { + val next = sortedItems[index] + val nextBegin = next.onStart.time + val nextEnd = next.onStop.time + + if (nextBegin <= currentEnd) { + if (nextEnd > currentEnd) { + currentEnd = nextEnd + } + continue + } + + mergedDurationSec += Duration.between(currentBegin, currentEnd).toMillis() / 1000.0 + currentBegin = nextBegin + currentEnd = nextEnd + } + + mergedDurationSec += Duration.between(currentBegin, currentEnd).toMillis() / 1000.0 + intervals.add(DurationOnRevDTO(revolution = revolution, durationSec = mergedDurationSec)) + } + + return intervals + } + + + fun rva(id : Long, duration : Long, station : StationDTO) : List { + + logger.info("Расчет ЗРВ для КА $id на $duration суток") + + val sat = satelliteById(id) + val ic = initialConditionsBySatelliteId(id) + val bal = Ballistics() +// val capture = 1.5 + val durations = listOf(10) + bal.modDVType = ModDVType.KONDOR_PROGNOZ + var r = bal.calculateOrbPoints(ic, ic.point.t, ic.point.t + duration * 86400) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r") + r = bal.calculateZRV( + listOf( + PPI( + station.number, 1, station.position.lat * PI / 180, station.position.long * PI / 180, + station.position.height, station.elevationMin * PI / 180, station.elevationMax * PI / 180 + ) + ), + ic.point.t, ic.point.t + duration * 86400 + ) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета ЗРВ: $r") + return bal.zrv.map { mkRVA(it, id) } + } + + fun publishInitialConditions(satelliteId: Long, t : LocalDateTime, onASC : Boolean) { + satelliteById(satelliteId) + val initialConditions = initialConditionsBySatelliteId(satelliteId) + + val tInt = fromDateTime(t) + + if (tInt < initialConditions.point.t) + throw CustomValidationException("Время запроса не должно быть меньше времени прогнозной орбиты (${toDateTime(initialConditions.point.t)})") + + val bal = Ballistics() + bal.modDVType = ModDVType.KONDOR_PROGNOZ + val r = bal.calculateOrbPoints(initialConditions, initialConditions.point.t, tInt+3 * 60 * 60) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r") + + val p = bal.points.filter { it.t > tInt }.minByOrNull { it.t } ?: + throw CustomErrorException("Не найдена точка массива ВУЗов") + + satelliteIcKafkaPublisher.publish( + SatelliteICDTO( + satelliteId = satelliteId, + ic = InitialConditions( + point = p, + sBall = initialConditions.sBall, + f81 = initialConditions.f81, + ).toDto() + ) + ) + } + + fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO( + toDateTime(p.t), + p.elevation * 180 / PI, + p.azimuth * 180 / PI, + p.range + ) + + fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO( + noradId = id, + stationId = p.ppi.toLong(), + revolution = p.vit.toLong(), + onStart = mkTP(p.zoneIn), + onMaximum = mkTP(p.zoneMax), + onStop = mkTP(p.zoneOut), + duration = (p.zoneOut.t - p.zoneIn.t) + ) + + fun init(sats : List, slotDuration: Long, cycleDuration : Long, revolutionDuration : Long, recover : Boolean? ) { + for (k in sats) { + logger.info("запуск для $k") + CoroutineScope(slotCalculationDispatcher).launch { + satelliteById(k) + + logger.info("старт для $k") + val ic = initialConditionsBySatelliteId(k) + calcSlots(k, ic, slotDuration, cycleDuration, revolutionDuration, true) + } + } + } + + private suspend fun calcSlots(id: Long, ic: InitialConditions, slotDuration: Long, cycleDuration : Long, revolutionDuration : Long, recov: Boolean) { + val sat = satelliteById(id) + val bal = Ballistics() + bal.modDVType = ModDVType.KONDOR_PROGNOZ + + sat.durationCalc = cycleDuration + sat.cycleRevs = revolutionDuration + sat.tnCalc = toDateTime(ic.point.t) + sat.slotDuration = slotDuration + updateSlotsProfile(sat) + val durations = listOf(sat.slotDuration) + + + var r = bal.calculateOrbPoints(ic, ic.point.t, ic.point.t + sat.durationCalc * 86400 + 3 * 60 * 60) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r") + r = bal.calculateFlightLine(ic.point.t, ic.point.t + sat.durationCalc * 86400 + 3 * 60 * 60) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r") + val rev = if (sat.cycleRevs.toInt() < bal.revolutions.toList().size) sat.cycleRevs.toInt() else bal.revolutions.toList().size - 1 + val tEnd = toDateTime(bal.revolutions.toList()[rev].vuz.t) + val sourcePoints = bal.points.toList() + if (sourcePoints.isEmpty()) { + throw CustomErrorException("Не созданы опорные точки орбиты для расчета слотов") + } + + val calculationStart = toDateTime(bal.revolutions.first().vuz.t) + val sourcePointStepSeconds = resolveSourcePointStepSeconds(sourcePoints) + val allChunks = durations.flatMap { duration -> + buildSlotCalculationChunks( + calculationStart = calculationStart, + calculationEndExclusive = tEnd, + durationSeconds = duration, + sourcePointStepSeconds = sourcePointStepSeconds + ) + }.mapIndexed { index, chunk -> + chunk.copy(index = index) + } + + logger.info( + "calcSlots start: satelliteId={}, chunks={}, durations={}, calcParallelism={}, chunkDurationSeconds={}, flushThreshold={}, streamBatchSize={}, sourcePointStepSeconds={}, sourceMarginSeconds={}, recov={}", + id, + allChunks.size, + durations, + slotCalcParallelism.coerceAtLeast(1), + slotCalcChunkDurationSeconds.coerceAtLeast(1), + slotCalcFlushThreshold.coerceAtLeast(1), + slotCalcStreamBatchSize.coerceAtLeast(1), + sourcePointStepSeconds, + slotCalcSourceMarginSeconds.coerceAtLeast(0), + recov + ) + + lateinit var writeSummary: SlotCalculationWriteSummary + val totalMs = measureTimeMillis { + coroutineScope { + val resultsChannel = Channel(slotCalcParallelism.coerceAtLeast(1) * 2) + val writer = async { + writeCalculatedSlots( + satelliteId = id, + results = resultsChannel, + totalChunks = allChunks.size, + coveringType = 0 + ) + } + + val producers = allChunks.map { chunk -> + async(slotCalculationDispatcher) { + computeSlotCalculationChunk( + satellite = sat, + satelliteId = id, + chunk = chunk, + sourcePoints = sourcePoints, + results = resultsChannel + ) + } + } + + val producerResult = runCatching { producers.awaitAll() } + if (producerResult.isSuccess) { + resultsChannel.close() + } else { + resultsChannel.close(producerResult.exceptionOrNull()) + } + + writeSummary = try { + writer.await() + } catch (writerError: Throwable) { + producerResult.exceptionOrNull()?.let { throw it } + throw writerError + } + producerResult.getOrThrow() + } + } + logger.info( + "calcSlots finish: satelliteId={}, chunks={}, slots={}, flushes={}, streamedBatches={}, maxPendingBatches={}, orbitalWindows={}, contourCalculations={}, orbitalWindowPrepareMs={}, contourBuildMs={}, computeCpuTotalMs={}, writeDurationMs={}, totalDurationMs={}, heaviestChunks={}", + id, + allChunks.size, + writeSummary.totalSlots, + writeSummary.flushCount, + writeSummary.streamedBatchCount, + writeSummary.maxPendingBatches, + writeSummary.orbitalWindowCount, + writeSummary.contourCalculationCount, + writeSummary.totalOrbitalWindowPrepareDurationMs, + writeSummary.totalContourBuildDurationMs, + writeSummary.totalComputeDurationMs, + writeSummary.writeDurationMs, + totalMs, + writeSummary.heaviestChunks.joinToString(separator = ",") { + "${it.index}:${it.slotCount}:${it.computeDurationMs}:${it.batchCount}" + }.ifBlank { "none" } + ) + } + + private fun batchInsert( + copyManager: CopyManager, + slots: List, + startSlotNum: Long, + coveringType: Int + ): Long { + if (slots.isEmpty()) return startSlotNum + + val copySql = """ + COPY slot ( + satellite_id, + covering_type, + slot_num, + cycle, + tn, + tk, + roll, + contour_wkt, + revolution, + revolution_sign, + latitude, + longitude + ) + FROM STDIN WITH ( + FORMAT text, + DELIMITER E'\t' + ) + """.trimIndent() + + val payload = buildCopyPayload(slots, startSlotNum, coveringType) + + StringReader(payload).use { reader -> + copyManager.copyIn(copySql, reader) + } + + return startSlotNum + slots.size + } + + private suspend fun writeCalculatedSlots( + satelliteId: Long, + results: ReceiveChannel, + totalChunks: Int, + coveringType: Int + ): SlotCalculationWriteSummary = withContext(Dispatchers.IO) { + dataSource.connection.use { connection -> + val previousAutoCommit = connection.autoCommit + connection.autoCommit = false + try { + deleteSlotsBySatellite(connection, satelliteId) + val copyManager = CopyManager(connection.unwrap(BaseConnection::class.java)) + val pending = linkedMapOf() + val buffer = ArrayList(slotCalcFlushThreshold.coerceAtLeast(1)) + var nextChunkIndex = 0 + var nextSlotNum = 1L + var flushCount = 0 + var totalSlots = 0 + var totalComputeDurationMs = 0L + var totalOrbitalWindowPrepareDurationMs = 0L + var totalContourBuildDurationMs = 0L + var orbitalWindowCount = 0 + var contourCalculationCount = 0 + var streamedBatchCount = 0 + var pendingBatchCount = 0 + var maxPendingBatches = 0 + val heaviestChunks = mutableListOf() + + val writeDurationMs = measureTimeMillis { + for (message in results) { + val pendingChunk = pending.getOrPut(message.index) { SlotCalculationPendingChunk() } + when (message) { + is SlotCalculationChunkBatch -> { + if (pendingChunk.completion != null) { + throw CustomErrorException("Получен batch после completion для чанка ${message.index}") + } + pendingChunk.batches += message.slots + pendingBatchCount++ + maxPendingBatches = maxOf(maxPendingBatches, pendingBatchCount) + } + is SlotCalculationChunkComplete -> { + if (pendingChunk.completion != null) { + throw CustomErrorException("Duplicate completion для чанка ${message.index}") + } + pendingChunk.completion = message + totalSlots += message.slotCount + streamedBatchCount += message.batchCount + totalComputeDurationMs += message.computeDurationMs + totalOrbitalWindowPrepareDurationMs += message.orbitalWindowPrepareDurationMs + totalContourBuildDurationMs += message.contourBuildDurationMs + orbitalWindowCount += message.orbitalWindowCount + contourCalculationCount += message.contourCalculationCount + trackHeaviestChunks(heaviestChunks, message) + } + } + + val (readyChunks, newNextChunkIndex) = drainReadyChunkResults(pending, nextChunkIndex) + readyChunks.forEach { readyChunk -> + val completion = readyChunk.completion + ?: throw CustomErrorException("Чанк без completion готов к записи: index=${nextChunkIndex}") + logger.debug( + "calcSlots stage=chunk-compute, satelliteId={}, chunkIndex={}, durationSeconds={}, slots={}, chunkBatches={}, orbitalWindows={}, contourCalculations={}, windowPrepareDurationMs={}, contourBuildDurationMs={}, computeDurationMs={}, generationStart={}, generationEndExclusive={}", + satelliteId, + completion.index, + completion.durationSeconds, + completion.slotCount, + completion.batchCount, + completion.orbitalWindowCount, + completion.contourCalculationCount, + completion.orbitalWindowPrepareDurationMs, + completion.contourBuildDurationMs, + completion.computeDurationMs, + completion.generationStart, + completion.generationEndExclusive + ) + readyChunk.batches.forEach { chunkBatch -> + pendingBatchCount-- + buffer.addAll(chunkBatch) + while (buffer.size >= slotCalcFlushThreshold.coerceAtLeast(1)) { + val batch = ArrayList(buffer.subList(0, slotCalcFlushThreshold.coerceAtLeast(1))) + nextSlotNum = batchInsert(copyManager, batch, nextSlotNum, coveringType) + buffer.subList(0, batch.size).clear() + flushCount++ + } + } + } + nextChunkIndex = newNextChunkIndex + } + + if (nextChunkIndex != totalChunks) { + throw CustomErrorException("Не все чанки расчета слотов были обработаны: processed=$nextChunkIndex, total=$totalChunks") + } + if (pending.isNotEmpty()) { + throw CustomErrorException("Остались необработанные чанки расчета слотов: ${pending.keys.sorted()}") + } + if (pendingBatchCount != 0) { + throw CustomErrorException("В pending очереди остались необработанные batch-и расчета слотов: $pendingBatchCount") + } + if (buffer.isNotEmpty()) { + nextSlotNum = batchInsert(copyManager, buffer.toList(), nextSlotNum, coveringType) + buffer.clear() + flushCount++ + } + } + + connection.commit() + SlotCalculationWriteSummary( + totalSlots = totalSlots, + flushCount = flushCount, + writeDurationMs = writeDurationMs, + totalComputeDurationMs = totalComputeDurationMs, + totalOrbitalWindowPrepareDurationMs = totalOrbitalWindowPrepareDurationMs, + totalContourBuildDurationMs = totalContourBuildDurationMs, + orbitalWindowCount = orbitalWindowCount, + contourCalculationCount = contourCalculationCount, + streamedBatchCount = streamedBatchCount, + maxPendingBatches = maxPendingBatches, + heaviestChunks = heaviestChunks.sortedByDescending { it.computeDurationMs } + ) + } catch (e: Exception) { + connection.rollback() + throw e + } finally { + connection.autoCommit = previousAutoCommit + } + } + } + + private fun deleteSlotsBySatellite(connection: java.sql.Connection, satelliteId: Long) { + connection.prepareStatement("DELETE FROM slot WHERE satellite_id = ?").use { statement -> + statement.setLong(1, satelliteId) + statement.executeUpdate() + } + } + + private fun trackHeaviestChunks( + heaviestChunks: MutableList, + result: SlotCalculationChunkComplete + ) { + heaviestChunks += result + heaviestChunks.sortByDescending { it.computeDurationMs } + if (heaviestChunks.size > 3) { + heaviestChunks.removeAt(heaviestChunks.lastIndex) + } + } + + private fun drainReadyChunkResults( + pending: MutableMap, + nextChunkIndex: Int + ): Pair, Int> { + val readyChunks = mutableListOf() + var currentChunkIndex = nextChunkIndex + while (true) { + val readyChunk = pending[currentChunkIndex] ?: break + if (readyChunk.completion == null) { + break + } + pending.remove(currentChunkIndex) + readyChunks += readyChunk + currentChunkIndex++ + } + return readyChunks to currentChunkIndex + } + + private fun buildSlotCalculationChunks( + calculationStart: LocalDateTime, + calculationEndExclusive: LocalDateTime, + durationSeconds: Long, + sourcePointStepSeconds: Long + ): List { + if (calculationStart >= calculationEndExclusive) { + return emptyList() + } + + val generationStepSeconds = (durationSeconds / 2).coerceAtLeast(1) + val chunkSpanSeconds = slotCalcChunkDurationSeconds + .coerceAtLeast(generationStepSeconds) + .let { configuredSeconds -> + val stepsPerChunk = (configuredSeconds / generationStepSeconds).coerceAtLeast(1) + stepsPerChunk * generationStepSeconds + } + val sourceMarginSeconds = maxOf( + slotCalcSourceMarginSeconds.coerceAtLeast(0), + durationSeconds + CONTOUR_SAMPLE_STEP_SECONDS, + sourcePointStepSeconds * 2 + ) + + val chunks = mutableListOf() + var chunkStart = calculationStart + var chunkIndex = 0 + while (chunkStart < calculationEndExclusive) { + val chunkEndExclusive = minOf(chunkStart.plusSeconds(chunkSpanSeconds), calculationEndExclusive) + chunks += SlotCalculationChunk( + index = chunkIndex++, + durationSeconds = durationSeconds, + generationStart = chunkStart, + generationEndExclusive = chunkEndExclusive, + generationStepSeconds = generationStepSeconds, + sourceWindowStartSeconds = fromDateTime(chunkStart.minusSeconds(sourceMarginSeconds)), + sourceWindowEndSeconds = fromDateTime(chunkEndExclusive.plusSeconds(durationSeconds + sourceMarginSeconds)) + ) + chunkStart = chunkEndExclusive + } + return chunks + } + + private fun resolveSourcePointStepSeconds(sourcePoints: List): Long = + sourcePoints + .zipWithNext() + .mapNotNull { (left, right) -> + val delta = (right.t - left.t).roundToLong() + delta.takeIf { it > 0 } + } + .minOrNull() + ?.coerceAtLeast(1) + ?: 60L + + private suspend fun computeSlotCalculationChunk( + satellite: AbstractSatellite, + satelliteId: Long, + chunk: SlotCalculationChunk, + sourcePoints: List, + results: SendChannel + ) { + val filteredPoints = sourcePoints.filter { point -> + point.t >= chunk.sourceWindowStartSeconds && point.t <= chunk.sourceWindowEndSeconds + } + if (filteredPoints.isEmpty()) { + throw CustomErrorException( + "Не найдены опорные точки орбиты для чанка расчета слотов: satelliteId=$satelliteId, chunkIndex=${chunk.index}" + ) + } + + val pz = EarthPZ90d02() + val stepper = RungeStepper(filteredPoints.toMutableList(), EarthType.PZ90d02) + val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + val geometryFactory = GeometryFactory() + val wktWriter = WKTWriter() + val producerBatchSize = slotCalcStreamBatchSize.coerceAtLeast(1) + val batchBuffer = ArrayList(producerBatchSize) + var emittedSlotCount = 0 + var emittedBatchCount = 0 + var orbitalWindowPrepareDurationMs = 0L + var contourBuildDurationMs = 0L + var orbitalWindowCount = 0 + var contourCalculationCount = 0 + val computeDurationMs = measureTimeMillis { + var tn = chunk.generationStart + while (tn < chunk.generationEndExclusive) { + val tk = tn.plusSeconds(chunk.durationSeconds) + val op = stepper.calculate(fromDateTime(tn)) + ?: throw CustomErrorException("Ошибка расчета выхода на заданное время для чанка ${chunk.index}") + val pointOnSurface = pz.xyz2blh(op.r) + if (abs(pointOnSurface.lat * 180 / PI) < 85) { + lateinit var preparedWindow: PreparedOrbitalWindow + orbitalWindowPrepareDurationMs += measureTimeMillis { + preparedWindow = prepareOrbitalWindow(stepper, tn, tk, op) + } + orbitalWindowCount++ + + var angleIndex = 0 + while (angleIndex < satellite.angles.size) { + val gammaMin = satellite.angles[angleIndex] + val gammaMax = satellite.angles[angleIndex + 1] + val gamma = (gammaMax + gammaMin) / 2 + val capture = abs(gammaMax - gammaMin) / 2 + + var contour = "" + contourBuildDurationMs += measureTimeMillis { + contour = contour( + window = preparedWindow, + roll = gamma, + capture = capture, + pointOnEarthCalculator = pointOnEarthCalculator, + geometryFactory = geometryFactory, + wktWriter = wktWriter + ) + } + contourCalculationCount++ + + if (contour.isNotEmpty()) { + val normalizedContour = LongitudeWrapGeometry.normalizeToContinuous360Wkt(contour) + if (!normalizedContour.contains("MULTI")) { + val viewPoint = viewParams(pointOnEarthCalculator, op, gamma) + batchBuffer += Mar( + sat = satelliteId, + tn = tn, + tk = tk, + roll = gamma, + contour = normalizedContour, + poly = null, + vit = op.vit.toLong(), + revolutionSign = if (op.v.z > 0) RevolutionSign.ASC else RevolutionSign.DESC, + cycle = 0, + slotNum = 0, + latitude = viewPoint!!.lat * 180 / PI, + longitude = viewPoint.long * 180 / PI + ) + if (batchBuffer.size >= producerBatchSize) { + emittedSlotCount += emitSlotCalculationChunkBatch( + chunkIndex = chunk.index, + slots = batchBuffer, + results = results + ) + emittedBatchCount++ + } + } else { + logger.warn("{} : {}", satelliteId, normalizedContour) + } + } + angleIndex += 2 + } + } + tn = tn.plusSeconds(chunk.generationStepSeconds) + } + } + if (batchBuffer.isNotEmpty()) { + emittedSlotCount += emitSlotCalculationChunkBatch( + chunkIndex = chunk.index, + slots = batchBuffer, + results = results + ) + emittedBatchCount++ + } + results.send( + SlotCalculationChunkComplete( + index = chunk.index, + durationSeconds = chunk.durationSeconds, + generationStart = chunk.generationStart, + generationEndExclusive = chunk.generationEndExclusive, + slotCount = emittedSlotCount, + batchCount = emittedBatchCount, + computeDurationMs = computeDurationMs, + orbitalWindowPrepareDurationMs = orbitalWindowPrepareDurationMs, + contourBuildDurationMs = contourBuildDurationMs, + orbitalWindowCount = orbitalWindowCount, + contourCalculationCount = contourCalculationCount + ) + ) + } + + + private fun buildCopyPayload( + slots: List, + startSlotNum: Long, + coveringType: Int + ): String { + val sb = StringBuilder(slots.size * 350) + + slots.forEachIndexed { index, slot -> + sb.append(slot.sat).append('\t') + sb.append(coveringType).append('\t') + sb.append(startSlotNum + index).append('\t') + sb.append(1).append('\t') + sb.append(formatCopyTimestamp(slot.tn)).append('\t') + sb.append(formatCopyTimestamp(slot.tk)).append('\t') + sb.append(slot.roll).append('\t') + sb.append(escapeCopyText(slot.contour)).append('\t') + sb.append(slot.vit).append('\t') + sb.append(slot.revolutionSign.toString()).append('\t') + sb.append(slot.latitude).append('\t') + sb.append(slot.longitude).append('\n') + } + + return sb.toString() + } + + private fun formatCopyTimestamp(value: LocalDateTime): String = + value.format(copyTimestampFormatter) + + private suspend fun emitSlotCalculationChunkBatch( + chunkIndex: Int, + slots: MutableList, + results: SendChannel + ): Int { + if (slots.isEmpty()) { + return 0 + } + val batch = ArrayList(slots) + slots.clear() + results.send( + SlotCalculationChunkBatch( + index = chunkIndex, + slots = batch + ) + ) + return batch.size + } + + private fun escapeCopyText(value: String): String { + val sb = StringBuilder(value.length + 16) + value.forEach { ch -> + when (ch) { + '\\' -> sb.append("\\\\") + '\t' -> sb.append("\\t") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + else -> sb.append(ch) + } + } + return sb.toString() + } + + +// private fun batchInsert(slots: List, startSlotNum: Long, coveringType: Int): Long { +// var currentSlotNum = startSlotNum +// +// var offset = 0 +// while (offset < slots.size) { +// val end = minOf(offset + SLOT_INSERT_BATCH_SIZE, slots.size) +// val chunk = slots.subList(offset, end) +// jdbcTemplate.batchUpdate( +// """ +// INSERT INTO slot ( +// satellite_id, +// covering_type, +// slot_num, +// cycle, +// tn, +// tk, +// roll, +// contour_wkt, +// revolution, +// revolution_sign, +// latitude, +// longitude +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent(), +// object : BatchPreparedStatementSetter { +// override fun setValues(ps: java.sql.PreparedStatement, i: Int) { +// val slot = chunk[i] +// ps.setLong(1, slot.sat) +// ps.setInt(2, coveringType) +// ps.setLong(3, currentSlotNum + i) +// ps.setLong(4, 1) +// ps.setTimestamp(5, Timestamp.valueOf(slot.tn)) +// ps.setTimestamp(6, Timestamp.valueOf(slot.tk)) +// ps.setDouble(7, slot.roll) +// ps.setString(8, slot.contour) +// ps.setLong(9, slot.vit) +// ps.setString(10, slot.revolutionSign.toString()) +// ps.setDouble(11, slot.latitude) +// ps.setDouble(12, slot.longitude) +// } +// override fun getBatchSize(): Int = chunk.size +// } +// ) +// currentSlotNum += chunk.size +// offset = end +// } +// +// return currentSlotNum +// } + + private fun prepareOrbitalWindow( + stepper: AbstractStepper, + tn: LocalDateTime, + tk: LocalDateTime, + initialPoint: OrbitalPoint? = null + ): PreparedOrbitalWindow { + val durationSeconds = Duration.between(tn, tk).seconds.coerceAtLeast(0) + val expectedPointCount = ((durationSeconds / CONTOUR_SAMPLE_STEP_SECONDS) + 1).toInt() + val points = ArrayList(expectedPointCount) + + var t = tn + if (initialPoint != null) { + points += initialPoint + t = t.plusSeconds(CONTOUR_SAMPLE_STEP_SECONDS) + } + while (t <= tk) { + points += stepper.calculate(fromDateTime(t)) + ?: throw CustomErrorException("Ошибка расчета выхода на заданное время") + t = t.plusSeconds(CONTOUR_SAMPLE_STEP_SECONDS) + } + + return PreparedOrbitalWindow( + start = tn, + endInclusive = tk, + points = points + ) + } + + private fun contour( + stepper: AbstractStepper, + tn: LocalDateTime, + tk: LocalDateTime, + roll: Double, + capture: Double + ): String = + contour( + window = prepareOrbitalWindow(stepper, tn, tk), + roll = roll, + capture = capture + ) + + private fun contour( + window: PreparedOrbitalWindow, + roll: Double, + capture: Double + ): String = + contour( + window = window, + roll = roll, + capture = capture, + pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit), + geometryFactory = GeometryFactory(), + wktWriter = WKTWriter() + ) + + private fun contour( + window: PreparedOrbitalWindow, + roll: Double, + capture: Double, + pointOnEarthCalculator: PointOnEarthCalculator, + geometryFactory: GeometryFactory, + wktWriter: WKTWriter + ): String { + try { + val rightCoordinates = ArrayList(window.points.size) + val leftCoordinates = ArrayList(window.points.size) + + window.points.forEach { point -> + val rightPoint = viewParams(pointOnEarthCalculator, point, roll + capture) ?: THBLPoint( + 0.0, + 0.0, + 0.0, + 0.0 + ) + rightCoordinates += Coordinate(rightPoint.long * 180 / PI, rightPoint.lat * 180 / PI, 0.0) + + val leftPoint = viewParams(pointOnEarthCalculator, point, roll - capture) ?: THBLPoint( + 0.0, + 0.0, + 0.0, + 0.0 + ) + leftCoordinates += Coordinate(leftPoint.long * 180 / PI, leftPoint.lat * 180 / PI, 0.0) + } + + val geom = ArrayList(rightCoordinates.size + leftCoordinates.size + 1) + geom.addAll(rightCoordinates) + for (index in leftCoordinates.indices.reversed()) { + geom += leftCoordinates[index] + } + if (geom.isNotEmpty()) { + geom += Coordinate(geom.first().x, geom.first().y) + } + + val shell = geometryFactory.createLinearRing(geom.toTypedArray()) + val polygon = geometryFactory.createPolygon(shell) + return wktWriter.write(polygon) + } catch (e: Exception) { + logger.warn("Ошибка при расчете контура", e) + return "" + } + } + + private fun viewParams(pos: OrbitalPoint, gamma: Double): THBLPoint? { + val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit) + return viewParams(calculator, pos, gamma) + } + + private fun viewParams( + calculator: PointOnEarthCalculator, + pos: OrbitalPoint, + gamma: Double + ): THBLPoint? = + calculator.pointOnEarth(pos, Orientation(0.0, gamma * PI / 180.0, 0.0)) + + fun polySlots( + wkt: String, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sign: RevolutionSign?, + cov: Boolean? = null, + states: List? = null, + sats : List? = null, + sun : Double? = null, + coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY + ): Iterable { + val target = buildCoverageTargets( + items = listOf(SlotCoverageTargetDTO(targetId = 1, wkt = wkt)) + ).first() + val batchContext = buildCoverageBatchContext( + targets = listOf(target), + timeStart = timeStart, + timeStop = timeStop, + sats = sats, + occupiedIntervals = emptyList(), + sign = sign, + states = states + ) + val selection = selectCoverageSlots( + target = target, + batchContext = batchContext, + cov = cov, + sun = sun, + coverageStrategy = coverageStrategy + ) + logger.info( + "polySlots finish: targetId={}, candidateSlots={}, sunFilteredSlots={}, returnedSlots={}, sun={}", + target.targetId, + selection.candidateSlotsBeforeSunFilter, + selection.sunFilteredSlots, + selection.slots.size, + sun + ) + return selection.slots + } + + fun polySlotsBatch(body: SlotCoverageBatchRequestDTO): List { + if (body.items.isEmpty()) { + return emptyList() + } + + val startedAtMs = System.currentTimeMillis() + try { + var result = emptyList() + val totalMs = measureTimeMillis { + var targets = emptyList() + val targetsMs = measureTimeMillis { + targets = buildCoverageTargets(body.items) + } + logger.info( + "polySlotsBatch start: targets={}, satellites={}, stage=build-coverage-targets, durationMs={}, sun={}", + targets.size, + selectedSatelliteCount(body.satellites), + targetsMs, + body.sun + ) + + var batchContext = CoverageBatchContext( + selectedSatellites = emptyList(), + selectedSatellitesById = emptyMap(), + bookedSlotsBySatelliteId = emptyMap(), + rawSlotIdsByTargetIdAndSatelliteId = emptyMap(), + preparedSlotsBySatelliteIdAndSourceSlotId = emptyMap(), + occupiedIntervalsBySatelliteId = emptyMap(), + totalUniqueRawSlots = 0, + totalPreparedSlots = 0 + ) + val contextMs = measureTimeMillis { + batchContext = buildCoverageBatchContext( + targets = targets, + timeStart = body.timeStart, + timeStop = body.timeStop, + sats = body.satellites, + occupiedIntervals = body.occupiedIntervals.orEmpty(), + sign = body.revSign, + states = null + ) + } + logger.info( + "polySlotsBatch stage=build-batch-context, targets={}, satellites={}, occupiedIntervals={}, uniqueRawSlots={}, preparedSlots={}, durationMs={}", + targets.size, + batchContext.selectedSatellites.size, + batchContext.occupiedIntervalsBySatelliteId.values.sumOf { it.size }, + batchContext.totalUniqueRawSlots, + batchContext.totalPreparedSlots, + contextMs + ) + + val effectiveParallelism = resolveBatchParallelism(targets.size) + var indexedResults = emptyList() + val selectMs = measureTimeMillis { + indexedResults = runBlocking { + kotlinx.coroutines.withContext(Dispatchers.Default.limitedParallelism(effectiveParallelism)) { + targets.mapIndexed { index, target -> + async { + val selection = selectCoverageSlots( + target = target, + batchContext = batchContext, + cov = body.cov, + sun = body.sun, + coverageStrategy = body.coverageStrategy + ) + IndexedCoverageBatchResult( + index = index, + response = SlotCoverageBatchResponseDTO( + targetId = target.targetId, + slots = selection.slots + ), + sourceSlotIds = selection.sourceSlotIds, + preparedSlotsBeforeOccupiedFilter = selection.preparedSlotsBeforeOccupiedFilter, + occupiedFilteredSlots = selection.occupiedFilteredSlots, + candidateSlotsBeforeSunFilter = selection.candidateSlotsBeforeSunFilter, + sunFilteredSlots = selection.sunFilteredSlots, + solverInputSlots = selection.solverInputSlots, + solverOutputSlots = selection.solverOutputSlots, + candidateSelectionDurationMs = selection.candidateSelectionDurationMs, + solverDurationMs = selection.solverDurationMs, + totalDurationMs = selection.totalDurationMs + ) + } + }.awaitAll() + } + } + } + val orderedResults = indexedResults.sortedBy { it.index } + val totalSourceSlotIds = orderedResults.sumOf { it.sourceSlotIds } + val totalPreparedSlotsBeforeOccupiedFilter = orderedResults.sumOf { it.preparedSlotsBeforeOccupiedFilter } + val totalOccupiedFilteredSlots = orderedResults.sumOf { it.occupiedFilteredSlots } + val totalCandidateSlotsBeforeSunFilter = orderedResults.sumOf { it.candidateSlotsBeforeSunFilter } + val totalSunFilteredSlots = orderedResults.sumOf { it.sunFilteredSlots } + val totalSolverInputSlots = orderedResults.sumOf { it.solverInputSlots } + val totalSolverOutputSlots = orderedResults.sumOf { it.solverOutputSlots } + val totalCandidateSelectionMs = orderedResults.sumOf { it.candidateSelectionDurationMs } + val totalSolverMs = orderedResults.sumOf { it.solverDurationMs } + val slowestTargets = orderedResults + .sortedByDescending { it.totalDurationMs } + .take(10) + .joinToString(separator = "; ") { item -> + "target=${item.response.targetId},totalMs=${item.totalDurationMs},collectMs=${item.candidateSelectionDurationMs},solverMs=${item.solverDurationMs},raw=${item.sourceSlotIds},prepared=${item.preparedSlotsBeforeOccupiedFilter},occupiedFiltered=${item.occupiedFilteredSlots},beforeSun=${item.candidateSlotsBeforeSunFilter},sunFiltered=${item.sunFilteredSlots},solverInput=${item.solverInputSlots},selected=${item.solverOutputSlots}" + } + val heaviestCandidateTargets = orderedResults + .sortedByDescending { it.solverInputSlots } + .take(10) + .joinToString(separator = "; ") { item -> + "target=${item.response.targetId},solverInput=${item.solverInputSlots},solverMs=${item.solverDurationMs},selected=${item.solverOutputSlots}" + } + logger.info( + "polySlotsBatch stage=select-total, targets={}, satellites={}, parallelism={}, wallDurationMs={}, candidateSelectionCpuTotalMs={}, solverCpuTotalMs={}, returnedTargets={}, returnedSlots={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, solverInputSlots={}, solverOutputSlots={}", + targets.size, + batchContext.selectedSatellites.size, + effectiveParallelism, + selectMs, + totalCandidateSelectionMs, + totalSolverMs, + orderedResults.size, + orderedResults.sumOf { it.response.slots.size }, + totalSourceSlotIds, + totalPreparedSlotsBeforeOccupiedFilter, + totalOccupiedFilteredSlots, + totalCandidateSlotsBeforeSunFilter, + totalSunFilteredSlots, + totalSolverInputSlots, + totalSolverOutputSlots + ) + logger.info( + "polySlotsBatch target-metrics slowestTargets=[{}]", + slowestTargets.ifBlank { "none" } + ) + logger.info( + "polySlotsBatch target-metrics heaviestCandidateTargets=[{}]", + heaviestCandidateTargets.ifBlank { "none" } + ) + result = orderedResults.map { it.response } + } + logger.info( + "polySlotsBatch finish: targets={}, returnedTargets={}, returnedSlots={}, totalDurationMs={}", + body.items.size, + result.size, + result.sumOf { it.slots.size }, + totalMs + ) + return result + } catch (ex: Exception) { + logger.error( + "polySlotsBatch failed: targets={}, satellites={}, occupiedIntervals={}, coverageStrategy={}, elapsedMs={}", + body.items.size, + selectedSatelliteCount(body.satellites), + body.occupiedIntervals.orEmpty().size, + body.coverageStrategy, + System.currentTimeMillis() - startedAtMs, + ex + ) + throw ex + } + } + + private fun normalizeWktLongitudeTo360(wkt: String): String { + return LongitudeWrapGeometry.normalizeToContinuous360Wkt(wkt) + } + + private fun buildCoverageTargets(items: List): List = + items + .distinctBy { it.targetId } + .map { item -> + val normalizedWkt = normalizeWktLongitudeTo360(item.wkt) + CoverageTargetContext( + targetId = item.targetId, + normalizedWkt = normalizedWkt, + searchWkts = LongitudeWrapGeometry.buildSearchVariantsWkt(normalizedWkt) + ) + } + + private fun buildCoverageBatchContext( + targets: List, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sats: List?, + occupiedIntervals: List, + sign: RevolutionSign?, + states: List? + ): CoverageBatchContext { + val selectedSatellites = loadSatellites(sats) + if (selectedSatellites.isEmpty() || targets.isEmpty()) { + return CoverageBatchContext( + selectedSatellites = selectedSatellites, + selectedSatellitesById = emptyMap(), + bookedSlotsBySatelliteId = emptyMap(), + rawSlotIdsByTargetIdAndSatelliteId = emptyMap(), + preparedSlotsBySatelliteIdAndSourceSlotId = emptyMap(), + occupiedIntervalsBySatelliteId = emptyMap(), + totalUniqueRawSlots = 0, + totalPreparedSlots = 0 + ) + } + + val selectedSatellitesById = selectedSatellites.associateBy { it.id } + + 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 + ) + } + + val minCycle = cycleWindows.values.minOf { it.cycleBegin } + val maxCycle = cycleWindows.values.maxOf { it.cycleEnd } + val selectedSatelliteIds = selectedSatellites.map { it.id } + + lateinit var bookedSlotsBySatelliteId: Map> + val bookedFetchMs = measureTimeMillis { + val bookedSlots = bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween( + selectedSatelliteIds, + minCycle, + maxCycle + ) + val groupedBookedSlots = bookedSlots.groupBy { it.slot.satelliteId } + bookedSlotsBySatelliteId = selectedSatelliteIds.associateWith { satelliteId -> + val window = cycleWindows.getValue(satelliteId) + groupedBookedSlots[satelliteId] + .orEmpty() + .filter { booked -> booked.cycle in window.cycleBegin..window.cycleEnd } + } + } + logger.info( + "polySlotsBatch stage=spatial-booked-fetch, satellites={}, durationMs={}", + selectedSatelliteIds.size, + bookedFetchMs + ) + + lateinit var rawSlotIdsByTargetIdAndSatelliteId: Map>> + lateinit var uniqueSlotsBySatelliteId: Map> + var satelliteBaseWindowCount = 0 + var searchRequestCount = 0 + val slotsFetchMs = measureTimeMillis { + val satelliteBaseWindows = cycleWindows.values + .flatMap { window -> + (window.cycleBegin..window.cycleEnd).map { cycle -> + SatelliteBaseWindow( + satelliteId = window.satellite.id, + windowStart = timeStart.minusDays(cycle * window.satellite.durationCalc), + windowStop = timeStop.minusDays(cycle * window.satellite.durationCalc) + ) + } + } + .distinct() + satelliteBaseWindowCount = satelliteBaseWindows.size + val searchRequests = targets + .flatMap { target -> + target.searchWkts.flatMap { searchWkt -> + satelliteBaseWindows.map { baseWindow -> + BatchCoverageSearchRequest( + targetId = target.targetId, + satelliteId = baseWindow.satelliteId, + windowStart = baseWindow.windowStart, + windowStop = baseWindow.windowStop, + polygonWkt = searchWkt + ) + } + } + } + .distinct() + searchRequestCount = searchRequests.size + val slotRows = slotRepository.findTargetSlotRows( + searchRequests = searchRequests, + chunkSize = batchSpatialFetchChunkSize.coerceAtLeast(1) + ) + + val slotIdsByTargetId = targets.associate { target -> + target.targetId to linkedMapOf>() + }.toMutableMap() + val uniqueSlotsBySatellite = linkedMapOf>() + + slotRows.forEach { row -> + val slotsBySatelliteId = slotIdsByTargetId.getValue(row.targetId) + val uniqueSatelliteSlots = uniqueSlotsBySatellite.getOrPut(row.satelliteId) { linkedMapOf() } + val slot = uniqueSatelliteSlots.getOrPut(row.slotId) { row.toSlotEntity() } + val slotId = slot.slotId ?: return@forEach + val satelliteSlotIds = slotsBySatelliteId.getOrPut(slot.satelliteId) { linkedSetOf() } + satelliteSlotIds += slotId + } + + rawSlotIdsByTargetIdAndSatelliteId = targets.associate { target -> + val slotsBySatelliteId = slotIdsByTargetId.getValue(target.targetId) + target.targetId to buildMap { + selectedSatelliteIds.forEach { satelliteId -> + val slotIds = slotsBySatelliteId[satelliteId]?.toList().orEmpty() + if (slotIds.isNotEmpty()) { + put(satelliteId, slotIds) + } + } + } + } + uniqueSlotsBySatelliteId = uniqueSlotsBySatellite + val rawRowsBySatellite = slotRows.groupingBy { it.satelliteId }.eachCount() + val heaviestSatellites = rawRowsBySatellite.entries + .sortedByDescending { it.value } + .take(3) + .joinToString(separator = ",") { "${it.key}:${it.value}" } + logger.info( + "polySlotsBatch stage=spatial-slot-fetch-detail, targets={}, searchVariants={}, satelliteWindows={}, searchRequests={}, rowsRead={}, uniqueRawSlots={}, heaviestSatellites={}", + targets.size, + targets.sumOf { it.searchWkts.size }, + satelliteBaseWindows.size, + searchRequests.size, + slotRows.size, + uniqueSlotsBySatellite.values.sumOf { it.size }, + heaviestSatellites.ifBlank { "none" } + ) + } + logger.info( + "polySlotsBatch stage=spatial-slot-fetch, targets={}, searchVariants={}, satelliteWindows={}, searchRequests={}, satellites={}, chunkSize={}, durationMs={}", + targets.size, + targets.sumOf { it.searchWkts.size }, + satelliteBaseWindowCount, + searchRequestCount, + selectedSatelliteIds.size, + batchSpatialFetchChunkSize.coerceAtLeast(1), + slotsFetchMs + ) + + lateinit var preparedSlotsPrecompute: PreparedSlotsPrecomputeResult + val precomputePreparedMs = measureTimeMillis { + preparedSlotsPrecompute = precomputePreparedSlotsBySatellite( + selectedSatelliteIds = selectedSatelliteIds, + uniqueSlotsBySatelliteId = uniqueSlotsBySatelliteId, + bookedSlotsBySatelliteId = bookedSlotsBySatelliteId, + selectedSatellitesById = selectedSatellitesById, + timeStart = timeStart, + timeStop = timeStop, + sign = sign, + states = states + ) + } + logger.info( + "polySlotsBatch stage=precompute-prepared-slots, satellites={}, uniqueRawSlots={}, preparedSlots={}, durationMs={}", + selectedSatelliteIds.size, + uniqueSlotsBySatelliteId.values.sumOf { it.size }, + preparedSlotsPrecompute.totalPreparedSlots, + precomputePreparedMs + ) + + return CoverageBatchContext( + selectedSatellites = selectedSatellites, + selectedSatellitesById = selectedSatellitesById, + bookedSlotsBySatelliteId = bookedSlotsBySatelliteId, + rawSlotIdsByTargetIdAndSatelliteId = rawSlotIdsByTargetIdAndSatelliteId, + preparedSlotsBySatelliteIdAndSourceSlotId = preparedSlotsPrecompute.preparedSlotsBySatelliteIdAndSourceSlotId, + occupiedIntervalsBySatelliteId = OccupiedIntervalPrefilter.normalizeBySatellite( + occupiedIntervals = occupiedIntervals, + satellitesById = selectedSatellitesById + ), + totalUniqueRawSlots = uniqueSlotsBySatelliteId.values.sumOf { it.size }, + totalPreparedSlots = preparedSlotsPrecompute.totalPreparedSlots + ) + } + + private fun space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow.toSlotEntity(): SlotEntity = + SlotEntity( + slotId = slotId, + slotNum = slotNum, + cycle = cycle, + satelliteId = satelliteId, + coveringType = coveringType, + tn = tn, + tk = tk, + roll = roll, + contour = contour, + revolution = revolution, + revolutionSign = revolutionSign, + latitude = latitude, + longitude = longitude + ) + + private fun precomputePreparedSlotsBySatellite( + selectedSatelliteIds: List, + uniqueSlotsBySatelliteId: Map>, + bookedSlotsBySatelliteId: Map>, + selectedSatellitesById: Map, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sign: RevolutionSign?, + states: List? + ): PreparedSlotsPrecomputeResult { + var totalPreparedSlots = 0 + val preparedBySatellite = selectedSatelliteIds.associateWith { satelliteId -> + val satellite = selectedSatellitesById[satelliteId] ?: return@associateWith emptyMap() + val uniqueSlots = uniqueSlotsBySatelliteId[satelliteId]?.values?.toList().orEmpty() + if (uniqueSlots.isEmpty()) { + return@associateWith emptyMap() + } + val preparedSlots = satellite.prepareCoverageSlots( + slots = uniqueSlots, + bookedSlots = bookedSlotsBySatelliteId[satelliteId].orEmpty(), + timeStart = timeStart, + timeStop = timeStop, + sign = sign, + states = states + ) + totalPreparedSlots += preparedSlots.size + preparedSlots.groupBy( + keySelector = { it.sourceSlotId }, + valueTransform = { it } + ) + } + return PreparedSlotsPrecomputeResult( + preparedSlotsBySatelliteIdAndSourceSlotId = preparedBySatellite, + totalPreparedSlots = totalPreparedSlots + ) + } + + private fun selectCoverageSlots( + target: CoverageTargetContext, + batchContext: CoverageBatchContext, + cov: Boolean?, + sun: Double?, + coverageStrategy: SlotCoverageStrategy? + ): CoverageSelectionResult { + val totalStartedAtMs = System.currentTimeMillis() + val selectedSlots = mutableListOf() + val targetSlotIdsBySatelliteId = batchContext.rawSlotIdsByTargetIdAndSatelliteId[target.targetId].orEmpty() + val sourceSlotIds = targetSlotIdsBySatelliteId.values.sumOf { it.size } + var preparedSlotsBeforeOccupiedFilter = 0 + var occupiedFilteredSlots = 0 + var candidateSlotsBeforeSunFilter = 0 + var sunFilteredSlots = 0 + val candidateSelectionMs = measureTimeMillis { + targetSlotIdsBySatelliteId.forEach { (satelliteId, sourceSlotIdsForSatellite) -> + if (sourceSlotIdsForSatellite.isEmpty()) { + return@forEach + } + val satellite = batchContext.selectedSatellitesById[satelliteId] ?: return@forEach + val preparedSlotsBySourceSlotId = + batchContext.preparedSlotsBySatelliteIdAndSourceSlotId[satelliteId].orEmpty() + val preparedSlots = sourceSlotIdsForSatellite.flatMap { slotId -> + preparedSlotsBySourceSlotId[slotId].orEmpty() + }.map { it.slot } + preparedSlotsBeforeOccupiedFilter += preparedSlots.size + val filteredSlots = OccupiedIntervalPrefilter.filterCandidates( + candidateSlots = preparedSlots, + occupiedIntervals = batchContext.occupiedIntervalsBySatelliteId[satelliteId].orEmpty(), + satellite = satellite + ) + occupiedFilteredSlots += preparedSlots.size - filteredSlots.size + candidateSlotsBeforeSunFilter += filteredSlots.size + val sunFilterResult = filterBySunAngle(filteredSlots, sun) + sunFilteredSlots += sunFilterResult.filteredCount + selectedSlots.addAll(sunFilterResult.slots) + } + } + logger.debug( + "polySlotsBatch stage=collect-prepared-slots, targetId={}, satellites={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, candidateSlots={}, durationMs={}", + target.targetId, + targetSlotIdsBySatelliteId.size, + sourceSlotIds, + preparedSlotsBeforeOccupiedFilter, + occupiedFilteredSlots, + candidateSlotsBeforeSunFilter, + sunFilteredSlots, + selectedSlots.size, + candidateSelectionMs + ) + + if (cov == true) { + val effectiveCoverageStrategy = coverageStrategy ?: SlotCoverageStrategy.GREEDY + try { + var result = listOf() + val solverInputSlots = selectedSlots.size + val solverMs = measureTimeMillis { + result = when (effectiveCoverageStrategy) { + SlotCoverageStrategy.GREEDY -> { + val solver = OptimalReqCoverSolver(logger, batchContext.selectedSatellitesById) + solver.select(target.normalizedWkt, selectedSlots) + } + SlotCoverageStrategy.CONTINUOUS -> { + val solver = ContinuousReqCoverSolver(logger, batchContext.selectedSatellitesById) + solver.select(target.normalizedWkt, selectedSlots) + } + } + } + val totalMs = System.currentTimeMillis() - totalStartedAtMs + logger.info( + "polySlotsBatch target-metrics targetId={}, strategy={}, satellites={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, solverInputSlots={}, solverOutputSlots={}, collectMs={}, solverMs={}, totalMs={}", + target.targetId, + effectiveCoverageStrategy, + targetSlotIdsBySatelliteId.size, + sourceSlotIds, + preparedSlotsBeforeOccupiedFilter, + occupiedFilteredSlots, + candidateSlotsBeforeSunFilter, + sunFilteredSlots, + solverInputSlots, + result.size, + candidateSelectionMs, + solverMs, + totalMs + ) + return CoverageSelectionResult( + slots = result, + sourceSlotIds = sourceSlotIds, + preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter, + occupiedFilteredSlots = occupiedFilteredSlots, + candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter, + sunFilteredSlots = sunFilteredSlots, + solverInputSlots = solverInputSlots, + solverOutputSlots = result.size, + candidateSelectionDurationMs = candidateSelectionMs, + solverDurationMs = solverMs, + totalDurationMs = totalMs + ) + } catch (e: CustomErrorException) { + logger.error( + "polySlotsBatch stage=solver-failed, targetId={}, strategy={}, candidates={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, message={}", + target.targetId, + effectiveCoverageStrategy, + selectedSlots.size, + candidateSlotsBeforeSunFilter, + sunFilteredSlots, + e.message, + e + ) + throw e + } catch (e: Exception) { + logger.error( + "polySlotsBatch stage=solver-failed, targetId={}, strategy={}, candidates={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}", + target.targetId, + effectiveCoverageStrategy, + selectedSlots.size, + candidateSlotsBeforeSunFilter, + sunFilteredSlots, + e + ) + throw CustomErrorException( + "Ошибка расчета покрытия targetId=${target.targetId}, strategy=$effectiveCoverageStrategy: ${e.message ?: e::class.simpleName}" + ) + } + } + + val totalMs = System.currentTimeMillis() - totalStartedAtMs + logger.info( + "polySlotsBatch target-metrics targetId={}, strategy=NONE, satellites={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, solverInputSlots={}, solverOutputSlots={}, collectMs={}, solverMs=0, totalMs={}", + target.targetId, + targetSlotIdsBySatelliteId.size, + sourceSlotIds, + preparedSlotsBeforeOccupiedFilter, + occupiedFilteredSlots, + candidateSlotsBeforeSunFilter, + sunFilteredSlots, + selectedSlots.size, + selectedSlots.size, + candidateSelectionMs, + totalMs + ) + return CoverageSelectionResult( + slots = selectedSlots, + sourceSlotIds = sourceSlotIds, + preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter, + occupiedFilteredSlots = occupiedFilteredSlots, + candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter, + sunFilteredSlots = sunFilteredSlots, + solverInputSlots = selectedSlots.size, + solverOutputSlots = selectedSlots.size, + candidateSelectionDurationMs = candidateSelectionMs, + solverDurationMs = 0L, + totalDurationMs = totalMs + ) + } + + private fun resolveBatchParallelism(targetCount: Int): Int { + if (targetCount <= 1) { + return 1 + } + return batchParallelism.coerceAtLeast(1).coerceAtMost(targetCount) + } + + private fun filterBySunAngle(candidateSlots: List, requestedSun: Double?): SunFilterResult { + if (requestedSun == null) { + return SunFilterResult(slots = candidateSlots, filteredCount = 0) + } + + val filteredSlots = candidateSlots.filter { slot -> + calculateSunAngleDegrees(slot) > requestedSun + } + return SunFilterResult( + slots = filteredSlots, + filteredCount = candidateSlots.size - filteredSlots.size + ) + } + + private fun calculateSunAngleDegrees(slot: SlotDTO): Double { + val time = fromDateTime(slot.tn) + val position = astronomer.earth.blh2xyz( + slot.latitude / 180.0 * PI, + slot.longitude / 180.0 * PI, + 0.0 + ) + val angle = astronomer.sunAngle(time, position) + return angle * 180.0 / PI + } + + fun reqCover( + id: String, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sign: RevolutionSign?, + cov: Boolean?, + sats : List? = null, + sun : Double? = null, + coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY + ): Iterable { + logger.info("Запрос покрытия заявки $id") + val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!") + logger.info("Получена заявка : $req") + return polySlots( + req, + timeStart, + timeStop, + sign, + cov, + listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED), + sats, + sun, + coverageStrategy + ) + } + + + + + @Transactional(readOnly = true) + fun allBooked() = bookedSlotsRepository.findAll().map { it.toDTO() } + + @Transactional(readOnly = true) + fun allByInterval( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List { + if (timeStop < timeStart) { + throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart") + } + + 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 cycleBegin = startCycle - 1 + val cycleEnd = stopCycle + 1 + + return (cycleBegin..cycleEnd) + .flatMap { cycle -> + val baseWindowStart = timeStart.minusDays(cycle * satellite.durationCalc) + val baseWindowStop = timeStop.minusDays(cycle * satellite.durationCalc) + slotRepository + .findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc( + satelliteId = satelliteId, + timeStart = baseWindowStart, + timeStop = baseWindowStop + ) + .map { slot -> slot.shiftedToCycle(cycle, satellite) } + } + .filter { slot -> slot.tk > timeStart && slot.tn < timeStop } + .sortedWith( + compareBy { it.tn } + .thenBy { it.tk } + .thenBy { it.satelliteId } + .thenBy { it.slotNumber } + .thenBy { it.cycle } + ) + } + + @Transactional(readOnly = true) + fun slotCalculationSummaries(satelliteIds: List): List { + val distinctSatelliteIds = satelliteIds.distinct() + if (distinctSatelliteIds.isEmpty()) { + return emptyList() + } + + val summariesBySatelliteId = distinctSatelliteIds + .map { satelliteId -> slotRepository.slotCalculationSummary(satelliteId) } + .associate { summary -> + summary.satelliteId to SlotCalculationSummaryDTO.calculated( + satelliteId = summary.satelliteId, + slotCount = summary.slotCount, + minTime = summary.minTime, + maxTime = summary.maxTime, + minRevolution = summary.minRevolution, + maxRevolution = summary.maxRevolution, + ) + } + + return distinctSatelliteIds.map { satelliteId -> + summariesBySatelliteId[satelliteId] ?: SlotCalculationSummaryDTO(satelliteId = satelliteId) + } + } + + @Transactional(readOnly = true) + fun bookedBySatelliteAndInterval( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List { + if (timeStop < timeStart) + throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart") + + 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 cycleBegin = startCycle - 1 + val cycleEnd = stopCycle + 1 + + val bookedSlots = bookedSlotsRepository.findBySlot_SatelliteIdAndCycleBetween(satelliteId, cycleBegin, cycleEnd) + return buildSurveyChains(satellite, bookedSlots, timeStart, timeStop) + } + + @Transactional(readOnly = true) + fun bookedBySatellitesAndInterval( + satelliteIds: List, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List { + if (timeStop < timeStart) + throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart") + + if (satelliteIds.isEmpty()) { + return emptyList() + } + + 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 + satellite.id to (startCycle - 1 to stopCycle + 1) + } + val minCycle = cycleWindows.values.minOf { it.first } + val maxCycle = cycleWindows.values.maxOf { it.second } + val bookedSlots = bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween( + selectedSatellites.map { it.id }, + minCycle, + maxCycle + ).groupBy { it.slot.satelliteId } + + return selectedSatellites.map { satellite -> + SatelliteMissionBatchResponseDTO( + satelliteId = satellite.id, + slots = buildSurveyChains( + satellite = satellite, + bookedSlots = bookedSlots[satellite.id].orEmpty(), + timeStart = timeStart, + timeStop = timeStop + ) + ) + } + } + + private fun buildSurveyChains( + satellite: AbstractSatellite, + bookedSlots: List, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): List { + val orderedSlots = bookedSlots + .asSequence() + .map { booked -> + val tn = booked.slot.tn.plusDays(booked.cycle * satellite.durationCalc) + val tk = booked.slot.tk.plusDays(booked.cycle * satellite.durationCalc) + SlotDTO( + cycle = booked.cycle, + satelliteId = booked.slot.satelliteId, + tn = tn, + tk = tk, + roll = booked.slot.roll, + contour = booked.slot.contour, + revolution = booked.slot.revolution + booked.cycle * satellite.cycleRevs, + revolutionSign = RevolutionSign.valueOf(booked.slot.revolutionSign), + slotNumber = booked.slot.slotNum, + state = SlotStatus.BOOKED, + latitude = booked.slot.latitude, + longitude = booked.slot.longitude, + ) to (booked.bookedSlotId ?: 0) + } + .filter { (slot, _) -> slot.tk > timeStart && slot.tn < timeStop } + .sortedWith( + compareBy> { it.first.roll } + .thenBy { it.first.tn } + .thenBy { it.first.tk } + .thenBy { it.first.revolution } + .thenBy { it.first.slotNumber } + .thenBy { it.second } + ) + .toList() + + if (orderedSlots.isEmpty()) { + return emptyList() + } + + val chains = mutableListOf() + val firstSlot = orderedSlots.first().first + var chainStart = firstSlot.tn + var chainEnd = firstSlot.tk + var chainRoll = firstSlot.roll + var chainContour = firstSlot.contour + var chainRevolution = firstSlot.revolution + var chainRevolutionSign = firstSlot.revolutionSign + val chainSlotIds = mutableListOf(orderedSlots.first().second) + + for ((slot, bookedSlotId) in orderedSlots.drop(1)) { + val canMergeTime = canMergeInTime(chainStart, chainEnd, slot.tn, slot.tk) + val sameRoll = sameRoll(slot.roll, chainRoll) + + if (sameRoll && canMergeTime) { + val overlapsInTime = intersectsInTime(chainStart, chainEnd, slot.tn, slot.tk) + val geometryMergeAssessment = assessMergeableGeometry( + leftContour = chainContour, + rightContour = slot.contour, + satelliteId = satellite.id, + allowContinuityWithoutArea = !overlapsInTime + ) + if (!geometryMergeAssessment.mergeable) { + if (geometryMergeAssessment.geometryChecked) { + logChainMergeSkippedWithoutSufficientGeometry( + satelliteId = satellite.id, + chainStart = chainStart, + chainEnd = chainEnd, + chainRoll = chainRoll, + chainSlotIds = chainSlotIds, + slot = slot, + bookedSlotId = bookedSlotId, + intersectionArea = geometryMergeAssessment.intersectionArea ?: 0.0, + distance = geometryMergeAssessment.distance, + overlapsInTime = overlapsInTime + ) + } + chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds)) + chainStart = slot.tn + chainEnd = slot.tk + chainRoll = slot.roll + chainContour = slot.contour + chainRevolution = slot.revolution + chainRevolutionSign = slot.revolutionSign + chainSlotIds.clear() + chainSlotIds.add(bookedSlotId) + continue + } + val mergedContour = unionContoursIfPolygon( + baseContour = chainContour, + contourToAdd = slot.contour, + satelliteId = satellite.id, + chainStart = chainStart, + chainEnd = chainEnd, + chainRoll = chainRoll, + chainSlotIds = chainSlotIds, + slot = slot, + bookedSlotId = bookedSlotId + ) + if (mergedContour == null) { + chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds)) + chainStart = slot.tn + chainEnd = slot.tk + chainRoll = slot.roll + chainContour = slot.contour + chainRevolution = slot.revolution + chainRevolutionSign = slot.revolutionSign + chainSlotIds.clear() + chainSlotIds.add(bookedSlotId) + continue + } + chainStart = minOf(chainStart, slot.tn) + chainEnd = maxOf(chainEnd, slot.tk) + chainContour = mergedContour + chainSlotIds.add(bookedSlotId) + continue + } + + chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds)) + chainStart = slot.tn + chainEnd = slot.tk + chainRoll = slot.roll + chainContour = slot.contour + chainRevolution = slot.revolution + chainRevolutionSign = slot.revolutionSign + chainSlotIds.clear() + chainSlotIds.add(bookedSlotId) + } + + chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds)) + return chains + } + + private data class GeometryMergeAssessment( + val mergeable: Boolean, + val intersectionArea: Double? = null, + val touchesOrIntersects: Boolean = false, + val distance: Double? = null, + val geometryChecked: Boolean = true + ) + + private fun buildSurveyChain( + satelliteId: Long, + orderedSlots: List>, + chainStart: LocalDateTime, + chainEnd: LocalDateTime, + chainRoll: Double, + chainContour: String, + chainRevolution: Long, + chainRevolutionSign: RevolutionSign, + chainSlotIds: List + ): SurveySlotDTO { + val slotBegin = orderedSlots + .filter { booked -> chainSlotIds.contains(booked.second) } + .map { it.first } + .sortedBy { slot -> slot.tn } + + return SurveySlotDTO( + satelliteId = satelliteId, + tn = chainStart, + tk = chainEnd, + roll = chainRoll, + contour = chainContour, + revolution = chainRevolution, + revolutionSign = chainRevolutionSign, + slotIds = chainSlotIds.distinct(), + latitude = slotBegin.first().latitude, + longitude = slotBegin.first().longitude + ) + } + + private fun canMergeInTime( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime + ): Boolean { + val firstStop = minOf(stop1, stop2) + val secondStart = maxOf(start1, start2) + if (!secondStart.isAfter(firstStop)) { + return true + } + val gapSeconds = Duration.between(firstStop, secondStart).seconds + return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS + } + + private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON + + private fun intersectsInTime( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime + ): Boolean = start1 < stop2 && start2 < stop1 + + private fun assessMergeableGeometry( + leftContour: String, + rightContour: String, + satelliteId: Long, + allowContinuityWithoutArea: Boolean + ): GeometryMergeAssessment { + return try { + val reader = WKTReader() + val leftGeometry = reader.read(leftContour) + val rightGeometry = reader.read(rightContour) + logInvalidGeometryIfNeeded(satelliteId, "chain", leftGeometry) + logInvalidGeometryIfNeeded(satelliteId, "candidate", rightGeometry) + val intersectionArea = leftGeometry.intersection(rightGeometry).area + val touchesOrIntersects = leftGeometry.intersects(rightGeometry) || leftGeometry.touches(rightGeometry) + val distance = if (touchesOrIntersects) 0.0 else leftGeometry.distance(rightGeometry) + GeometryMergeAssessment( + mergeable = intersectionArea > GEOMETRY_INTERSECTION_AREA_EPSILON || + (allowContinuityWithoutArea && (touchesOrIntersects || distance <= GEOMETRY_DISTANCE_EPSILON)), + intersectionArea = intersectionArea, + touchesOrIntersects = touchesOrIntersects, + distance = distance + ) + } catch (e: Exception) { + logger.warn("Не удалось проверить непрерывность геометрий цепочки: {}", e.message) + GeometryMergeAssessment(mergeable = false, geometryChecked = false) + } + } + + private fun unionContoursIfPolygon( + baseContour: String, + contourToAdd: String, + satelliteId: Long, + chainStart: LocalDateTime, + chainEnd: LocalDateTime, + chainRoll: Double, + chainSlotIds: List, + slot: SlotDTO, + bookedSlotId: Long + ): String? { + return try { + val reader = WKTReader() + val baseGeometry = reader.read(baseContour) + val geometryToAdd = reader.read(contourToAdd) + val unionGeometry = baseGeometry.union(geometryToAdd) + if (unionGeometry !is Polygon) { + logger.warn( + "Chain merge skipped because union produced non-polygon geometry: satelliteId={}, geometryType={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotIds={}, slotId={}", + satelliteId, + unionGeometry.geometryType, + chainStart, + chainEnd, + slot.tn, + slot.tk, + chainRoll, + slot.roll, + chainSlotIds, + bookedSlotId + ) + return null + } + WKTWriter().write(unionGeometry) + } catch (e: Exception) { + logger.warn("Не удалось объединить контуры цепочки: {}", e.message) + null + } + } + + private fun logInvalidGeometryIfNeeded( + satelliteId: Long, + geometryRole: String, + geometry: Geometry + ) { + if (geometry.isValid) { + return + } + logger.warn( + "Chain merge geometry is invalid: satelliteId={}, geometryRole={}, geometryType={}", + satelliteId, + geometryRole, + geometry.geometryType + ) + } + + private fun logChainMergeSkippedWithoutSufficientGeometry( + satelliteId: Long, + chainStart: LocalDateTime, + chainEnd: LocalDateTime, + chainRoll: Double, + chainSlotIds: List, + slot: SlotDTO, + bookedSlotId: Long, + intersectionArea: Double, + distance: Double?, + overlapsInTime: Boolean + ) { + if (overlapsInTime) { + logger.warn( + "Chain merge skipped because contours lack sufficient areal intersection despite temporal overlap: satelliteId={}, intersectionArea={}, epsilon={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotIds={}, slotId={}", + satelliteId, + intersectionArea, + GEOMETRY_INTERSECTION_AREA_EPSILON, + chainStart, + chainEnd, + slot.tn, + slot.tk, + chainRoll, + slot.roll, + chainSlotIds, + bookedSlotId + ) + return + } + + logger.warn( + "Chain merge skipped because contours are not continuous enough despite mergeable time window: satelliteId={}, intersectionArea={}, areaEpsilon={}, distance={}, distanceEpsilon={}, maxTimeGapSeconds={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotIds={}, slotId={}", + satelliteId, + intersectionArea, + GEOMETRY_INTERSECTION_AREA_EPSILON, + distance, + GEOMETRY_DISTANCE_EPSILON, + MERGE_MAX_TIME_GAP_SECONDS, + chainStart, + chainEnd, + slot.tn, + slot.tk, + chainRoll, + slot.roll, + chainSlotIds, + bookedSlotId + ) + } + + @Transactional(readOnly = true) + fun bookedByReqId(id : String) : List { + return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.slot.toDTO(SlotStatus.valueOf(req.bookedSlot.status)) } + } + + + @Transactional(readOnly = true) + fun bookedById(id: Long) = bookedSlotsRepository.findByBookedSlotId(id) + .map { it.toDTO() } + .orElseThrow { CustomValidationException("Нет забронированного слота с идентификатором $id") } + + @Transactional + fun bookSlot(req: SlotBookingRequestDTO): BookedSlotDTO { + var slot: SlotEntity + + println(req) + logger.info("запрос бронирования слота для заявки ${req.requestId}") + + var tms = measureTimeMillis { + slot = slotRepository.findBySatelliteIdAndSlotNum(req.satelliteId, req.slotNum).getOrNull() + ?: (throw CustomValidationException("Слот с заданными параметрами не существует.")) + } + logger.info("Запрос слота за $tms мс") + var bookedSlot: BookedSlotEntity? + tms = measureTimeMillis { + bookedSlot = bookedSlotsRepository.findBySlot_SlotIdAndCycle(slot.slotId ?: -1, req.cycle) + } + logger.info("Запрос забронированного слота за $tms мс") + val slotDest = (bookedSlot ?: bookedSlotsRepository.save( + BookedSlotEntity( + slot, req.cycle + ) + )) + requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(slotDest.bookedSlotId?:-1, req.requestId?:"test")?: + requestRepository.save(BookedRequestEntity(null, slotDest, req.requestId?:"test")) + return slotDest.toDTO() + } + + @Transactional + fun bookReq(req : BookingRequestDTO) : Iterable{ + logger.info("Запрос на бронирование по заявке ${req.requestId} ${req.slots.count()} слотов") + return req.slots.map { slot -> + bookSlot(slot.apply { requestId = req.requestId }) + } + } + + + @Transactional + fun cancelReq(req : String) : Int{ + logger.info("Запрос на отмену бронирование по заявке ${req}") + var cnt = 0 + requestRepository.findByRequestId(req).forEach { booked -> + if (booked.bookedSlot.requests?.size == 1) + bookedSlotsRepository.deleteByBookedSlotId(booked.bookedSlot.bookedSlotId?:-1) + else + requestRepository.deleteByBookedSlotRequestId( booked.bookedSlotRequestId?:-1) + ++cnt + } + return cnt + } + + + @Transactional + fun cancelSlot(bookedSlotId : Long) = + bookedSlotsRepository.deleteByBookedSlotId(bookedSlotId) + + fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? { + val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null + return bookedSlot.slot.tn.plusDays(bookedSlot.cycle * satellite.durationCalc) + } + + + + + + + + + + + private fun loadSatellites(ids: List?): List = when { + ids == null -> satelliteCatalogClient.allSatellites() + ids.isEmpty() -> emptyList() + else -> satelliteCatalogClient.satellites(ids) + } + + private fun selectedSatelliteCount(ids: List?): Int = + if (ids == null) { + satelliteCatalogClient.allSatellites().size + } else { + ids.distinct().size + } + + private fun satelliteById(id: Long): AbstractSatellite = + satelliteCatalogClient.satellite(id) + + // После пересчета параметров синхронизируем slot-profile в каталоге спутников с текущим состоянием КА. + private fun updateSlotsProfile(satellite: AbstractSatellite) { + satelliteCatalogClient.updateSlotProfile( + satellite.id, + SatelliteSlotProfileDTO( + cycleRevs = satellite.cycleRevs, + tnCalc = satellite.tnCalc, + slotDuration = satellite.slotDuration, + durationCalcDays = satellite.durationCalc, + maxChainLength = satellite.maxChainLength, + defaultAngles = satellite.toSlotAngles() + ) + ) + } + + private fun AbstractSatellite.toSlotAngles(): List { + if (angles.size % 2 != 0) { + throw CustomValidationException("Для КА $id список углов slot-profile должен содержать четное количество значений") + } + + return angles.chunked(2).map { pair -> + SatelliteSlotAngleDTO( + angleBegin = pair[0], + angleEnd = pair[1] + ) + } + } + + private fun initialConditionsBySatelliteId(satelliteId: Long): InitialConditions = + satelliteIcService.initialConditions(satelliteId) + + private fun InitialConditions.toDto(): InitialConditionsDTO = + InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = toDateTime(point.t), + revolution = point.vit.toLong(), + vx = point.v.x, + vy = point.v.y, + vz = point.v.z, + x = point.r.x, + y = point.r.y, + z = point.r.z + ), + // Some catalog records do not provide ballistic coefficients; publish DTO defaults instead of nulls. + sBall = sBall ?: InitialConditionsDTO().sBall, + f81 = f81 ?: InitialConditionsDTO().f81 + ) + + private fun SlotEntity.shiftedToCycle(cycle: Long, satellite: AbstractSatellite): SlotDTO = + SlotDTO( + cycle = cycle, + satelliteId = satelliteId, + tn = tn.plusDays(cycle * satellite.durationCalc), + tk = tk.plusDays(cycle * satellite.durationCalc), + roll = roll, + contour = contour, + revolution = revolution + cycle * satellite.cycleRevs, + revolutionSign = RevolutionSign.valueOf(revolutionSign), + slotNumber = slotNum, + state = SlotStatus.AVAILABLE, + latitude = latitude, + longitude = longitude + ) +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/util/LongitudeWrapGeometry.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/util/LongitudeWrapGeometry.kt new file mode 100644 index 0000000..3a1141d --- /dev/null +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/util/LongitudeWrapGeometry.kt @@ -0,0 +1,172 @@ +package space.nstart.pcp.slots_service.util + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.CoordinateSequenceFilter +import org.locationtech.jts.geom.Envelope +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.geom.GeometryCollection +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.LinearRing +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser +import kotlin.math.abs +import kotlin.math.floor + +object LongitudeWrapGeometry { + + private val parserThreadLocal = ThreadLocal.withInitial { WKTParser() } + private val geometryFactory = GeometryFactory() + + private fun parser(): WKTParser = parserThreadLocal.get() + + fun normalizeToContinuous360Wkt(wkt: String): String = + parser().toWKT(normalizeToContinuous360(parser().parseWKT(wkt))) + + fun buildSearchVariantsWkt(wkt: String): List = + buildSearchVariants(parser().parseWKT(wkt)).map(parser()::toWKT).distinct() + + fun normalizeToContinuous360(geometry: Geometry): Geometry { + val normalized = shiftNegativeLongitudesTo360(geometry.copy()) + return when (normalized) { + is Polygon -> normalizePolygon(normalized) + is MultiPolygon -> normalizeMultiPolygon(normalized) + else -> normalized + }.let { if (it.isValid) it else it.buffer(0.0) } + } + + fun buildSearchVariants(geometry: Geometry): List { + val normalized = normalizeToContinuous360(geometry) + return listOf( + shiftLongitude(normalized, -360.0), + normalized, + shiftLongitude(normalized, 360.0) + ).distinctBy { parser().toWKT(it) } + } + + fun alignToReference(geometry: Geometry, reference: Geometry): Geometry { + val normalizedReference = normalizeToContinuous360(reference) + val referenceCenter = envelopeCenterX(normalizedReference.envelopeInternal) + + return buildSearchVariants(geometry) + .minByOrNull { abs(envelopeCenterX(it.envelopeInternal) - referenceCenter) } + ?: normalizeToContinuous360(geometry) + } + + private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry { + geometry.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + val lon = seq.getX(i) + if (lon < 0.0) { + seq.setOrdinate(i, 0, lon + 360.0) + } + } + + override fun isDone(): Boolean = false + + override fun isGeometryChanged(): Boolean = true + }) + geometry.geometryChanged() + return geometry + } + + private fun normalizePolygon(polygon: Polygon): Geometry { + val shell = unwrapRing(polygon.exteriorRing) + val holes = Array(polygon.numInteriorRing) { index -> + unwrapRing(polygon.getInteriorRingN(index)) + } + + return shiftIntoCanonicalBand(repairGeometry( + polygon.factory.createPolygon(shell, holes) + )) + } + + private fun normalizeMultiPolygon(multiPolygon: MultiPolygon): Geometry { + val parts = mutableListOf() + for (index in 0 until multiPolygon.numGeometries) { + collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon), parts) + } + + return when (parts.size) { + 0 -> geometryFactory.createPolygon() + 1 -> parts.first() + else -> geometryFactory.createMultiPolygon(parts.toTypedArray()) + } + } + + private fun collectPolygons(geometry: Geometry, parts: MutableList) { + when (geometry) { + is Polygon -> parts += geometry + is MultiPolygon -> { + for (index in 0 until geometry.numGeometries) { + parts += geometry.getGeometryN(index) as Polygon + } + } + is GeometryCollection -> { + for (index in 0 until geometry.numGeometries) { + collectPolygons(geometry.getGeometryN(index), parts) + } + } + } + } + + private fun repairGeometry(geometry: Geometry): Geometry = + if (geometry.isValid) geometry else geometry.buffer(0.0) + + private fun shiftIntoCanonicalBand(geometry: Geometry): Geometry { + val minX = geometry.coordinates.minOfOrNull { it.x } ?: return geometry + if (minX >= 0.0) return geometry + + val shift = 360.0 * (floor(-minX / 360.0) + 1.0) + return shiftLongitude(geometry, shift) + } + + private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry { + if (delta == 0.0) return geometry + val shifted = geometry.copy() + shifted.apply(object : CoordinateSequenceFilter { + override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) { + seq.setOrdinate(i, 0, seq.getX(i) + delta) + } + + override fun isDone(): Boolean = false + + override fun isGeometryChanged(): Boolean = true + }) + shifted.geometryChanged() + return shifted + } + + private fun envelopeCenterX(envelope: Envelope): Double = (envelope.minX + envelope.maxX) / 2.0 + + private fun unwrapRing(ring: org.locationtech.jts.geom.LineString): LinearRing { + val source = ring.coordinates + if (source.isEmpty()) { + return ring.factory.createLinearRing() + } + + val first = source.first() + val unwrapped = ArrayList(source.size) + var previousX = first.x + unwrapped.add(Coordinate(previousX, first.y, first.z)) + + for (index in 1 until source.size - 1) { + val current = source[index] + var adjustedX = current.x + + while (adjustedX - previousX > 180.0) { + adjustedX -= 360.0 + } + while (adjustedX - previousX < -180.0) { + adjustedX += 360.0 + } + + unwrapped.add(Coordinate(adjustedX, current.y, current.z)) + previousX = adjustedX + } + + val start = unwrapped.first() + unwrapped.add(Coordinate(start.x, start.y, start.z)) + return ring.factory.createLinearRing(unwrapped.toTypedArray()) + } +} diff --git a/services/slots-service/src/main/resources/application.yaml b/services/slots-service/src/main/resources/application.yaml new file mode 100644 index 0000000..c40df22 --- /dev/null +++ b/services/slots-service/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + application: + name: slots-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} diff --git a/services/slots-service/src/main/resources/db/migration/V10__satellite_ic.sql b/services/slots-service/src/main/resources/db/migration/V10__satellite_ic.sql new file mode 100644 index 0000000..254bb07 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V10__satellite_ic.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS satellite_ic( + satellite_id BIGINT PRIMARY KEY, + 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 +); + +INSERT INTO satellite_ic (satellite_id, orb_time, orb_revolution, x, y, z, vx, vy, vz, s_ball,f81) VALUES + (1, '2026-03-20 07:20:00.000000000', 1, 6834436.888999999, 597938.4140000014, -0.3770000001939167, 137.3883790000003, 1464.748528, 7562.254627000002, 0.0, 0.0), + (2, '2026-03-20 07:20:00.000000000', 1, -3266701.5884873797, 1278725.1552186068, 5884819.247744364, 6697.319996679769, -307.8285855300839, 3775.916576094365, 0.0, 0.0), + (3, '2026-03-20 07:20:00.000000000', 1, 3508050.1042085, -23618.976590790862, 5884949.766167523, 6334.124848683056, -2197.290023159083, -3775.6117154309222, 0.0, 0.0), + (4, '2026-03-20 07:20:00.000000000', 1, 6569209.44012876, -1978849.514208376, 8.549715463940899, -415.5998592415322, -1411.299509223835, -7561.962819171092, 0.0, 0.0), + (5, '2026-03-20 07:20:00.000000000', 1, 2943239.937782717, -1922373.245130921, -5897170.487022758, -6476.784913434777, 1661.054349866645, -3775.66202329109, 0.0, 0.0), + (6, '2026-03-20 07:20:00.000000000', 1, -3436251.306747606, 741544.7543712078, -5897104.773002284, -5734.695868980442, 3438.16424470693, 3775.7766970312277, 0.0, 0.0), + (7, '2026-03-20 03:00:00.000000000', 1, -6343478.396000003, 2627554.784, -1.77635683940025e-12, 574.4640830000003, 1362.170732, 7558.135535, 0.0588, 125.0), + (8, '2026-03-20 03:18:50.782779625', 1, -1500868.337345541, 1713488.271989074, 6465579.621686231, 6712.464186248605, -2938.606060145184, 2331.536180172411, 0.0588, 125.0), + (9, '2026-03-20 03:37:40.764922495', 1, 4984345.125556404, -2494614.053184607, 3998726.389771715, 3211.515143045879, -3408.641923514943, -6111.022893681113, 0.0588, 125.0), + (10, '2026-03-20 03:56:32.881376251', 1, 4150155.5063736434, -3732737.1762040304, -4004553.1452223673, -4331.971891773795, 1731.421783096582, -6111.015697381638, 0.0588, 125.0), + (11, '2026-03-20 04:15:27.865948215', 1, -2148327.9320683316, 773228.4988466053, -6480904.004374029, -5172.91956636886, 5163.260323770023, 2331.412682288123, 0.0588, 125.0), + (12, '2026-03-20 04:36:17.199469748', 1, -5577440.566626521, 2884295.721554679, 2768009.21001528, 3415.1909962371587, -47.07995218278599, 6902.524078471197, 0.0588, 125.0), + (13, '2026-03-20 04:36:17.199469748', 1, 1049585.1536136891, 422595.4885303282, 6760381.113752117, 6627.151703742502, -3816.4267934255963, -788.7301563455045, 0.0588, 125.0), + (14, '2026-03-20 04:36:17.199469748', 1, 5700410.634731486, -3551849.8950458337, 1415295.6418679939, 543.7800930870285, -2093.4677990699024, -7392.666893723815, 0.0588, 125.0), + (15, '2026-03-20 04:36:17.199469748', 1, 2226452.129165154, -2725645.970252924, -5901132.02228053, -5676.821938940451, 3529.089468671107, -3774.088216667501, 0.0588, 125.0), + (16, '2026-03-20 04:36:17.199469748', 1, -3812294.81281716, 2651900.7823911654, -5063730.148299142, -3500.162878099333, 4608.015564707656, 5051.928406182758, 0.0588, 125.0), + (17, '2026-03-20 06:12:34.090912147', 1, -3853599.6919466653, 2576048.3106887443, 5054325.968491229, 5584.613907982802, -1584.611512736432, 5052.09382171401, 0.0588, 125.0), + (18, '2026-03-20 06:12:34.090912147', 1, 3343662.265252321, -1073879.596285395, 5888481.632523625, 5376.226538726409, -4003.3181715893215, -3774.0209436005152, 0.0588, 125.0), + (19, '2026-03-20 06:12:34.090912147', 1, 5392946.66813025, -4009091.35838356, -1416020.9990885688, -2132.052876866671, -270.3084256740931, -7392.664064050133, 0.0588, 125.0), + (20, '2026-03-20 06:12:34.090912147', 1, -9878.150691137964, -1134172.345175515, -6777094.963096752, -5959.561661919599, 4762.465791485728, -788.8304355957544, 0.0588, 125.0), + (21, '2026-03-20 06:12:34.090912147', 1, -4726156.476778872, 4143869.212740232, -2770870.028395509, -1276.866626715071, 3149.351408771444, 6902.361873377728, 0.0588, 125.0), + (22, '2026-03-20 07:20:00.000000000', 1, -6834436.888999999, 597938.4140000014, -0.3770000001939167, 137.3883790000003, 1464.748528, 7562.254627000002, 0.0, 0.0), + (666, '2028-06-01 00:00:00.000000000', 1, 1206473.149, 415444.745, 6735835.213, 6875.6050000000005, -3278.8179999999998, -1037.9270000000001, 0.0, 125.0), + (667, '2028-06-01 00:00:00.000000000', 1, -1213286.869, -411426.514, -6728843.538, -6882.499, 3281.629, 1031.689, 0.0, 125.0), + (668, '2028-06-01 00:00:00.000000000', 1, 6145810.177, -2865214.215, -934946.8369999999, -1566.251, -904.4879999999999, -7499.759, 0.0, 125.0), + (669, '2028-06-01 00:00:00.000000000', 1, -6161752.687999999, 2870843.512, 924613.278, 1545.135, 913.259, 7485.861, 0.0, 125.0), + (62138, '2025-10-27 00:29:33.162630000', 5028, -5574448.0182, 4045996.9959, 0, 881.19655406, 1197.8366823, 7544.9922967, 0.0, 125.0), + (56756, '2025-10-27 1:27:51.310780000', 13427, -3477478.8547, 5945714.2979, 0, 1288.2677793, 742.41429008, 7545.0218967, 0.0, 125.0); diff --git a/services/slots-service/src/main/resources/db/migration/V11__satellite_ic_missing_rows.sql b/services/slots-service/src/main/resources/db/migration/V11__satellite_ic_missing_rows.sql new file mode 100644 index 0000000..d323c6f --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V11__satellite_ic_missing_rows.sql @@ -0,0 +1,14 @@ +INSERT INTO satellite_ic (satellite_id, orb_time, orb_revolution, x, y, z, vx, vy, vz, s_ball, f81) VALUES + (62138, '2025-10-27 00:29:33.162630000', 5028, -5574448.0182, 4045996.9959, 0, 881.19655406, 1197.8366823, 7544.9922967, 0.0, 125.0), + (56756, '2025-10-27 1:27:51.310780000', 13427, -3477478.8547, 5945714.2979, 0, 1288.2677793, 742.41429008, 7545.0218967, 0.0, 125.0) +ON CONFLICT (satellite_id) DO UPDATE SET + orb_time = EXCLUDED.orb_time, + orb_revolution = EXCLUDED.orb_revolution, + x = EXCLUDED.x, + y = EXCLUDED.y, + z = EXCLUDED.z, + vx = EXCLUDED.vx, + vy = EXCLUDED.vy, + vz = EXCLUDED.vz, + s_ball = EXCLUDED.s_ball, + f81 = EXCLUDED.f81; diff --git a/services/slots-service/src/main/resources/db/migration/V12__slot_summary_indexes.sql b/services/slots-service/src/main/resources/db/migration/V12__slot_summary_indexes.sql new file mode 100644 index 0000000..e5312f5 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V12__slot_summary_indexes.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_slot_satellite_tk ON slot(satellite_id, tk); +CREATE INDEX IF NOT EXISTS idx_slot_satellite_revolution ON slot(satellite_id, revolution); diff --git a/services/slots-service/src/main/resources/db/migration/V1__create_slots.sql b/services/slots-service/src/main/resources/db/migration/V1__create_slots.sql new file mode 100644 index 0000000..6b97640 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V1__create_slots.sql @@ -0,0 +1,24 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + +CREATE TABLE IF NOT EXISTS slot( + slot_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + covering_type INT NOT NULL, + slot_num BIGINT NOT NULL, + cycle BIGINT NOT NULL, + tn TIMESTAMP NOT NULL, + tk TIMESTAMP NOT NULL, + roll DOUBLE PRECISION, + contour_wkt TEXT NOT NULL, + revolution BIGINT NOT NULL, + revolution_sign VARCHAR(4) NOT NULL CHECK (revolution_sign IN ('ASC', 'DESC')) +); + +ALTER TABLE slot ADD COLUMN IF NOT EXISTS contour_geom geometry(Polygon, 4326) +GENERATED ALWAYS AS (ST_GeomFromText(contour_wkt)::geometry(Polygon, 4326)) STORED; + + +CREATE INDEX idx_covering_type ON slot(covering_type); +CREATE INDEX idx_satellite_id ON slot(satellite_id); +CREATE INDEX idx_revolution_sign ON slot(revolution_sign); +CREATE INDEX idx_slot_contour_geom ON slot USING GIST (contour_geom); diff --git a/services/slots-service/src/main/resources/db/migration/V2__booked_slots.sql b/services/slots-service/src/main/resources/db/migration/V2__booked_slots.sql new file mode 100644 index 0000000..0f135f2 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V2__booked_slots.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS booked_slot( + booked_slot_id BIGSERIAL PRIMARY KEY, + slot_id BIGINT NOT NULL, + cycle BIGINT NOT NULL, + FOREIGN KEY (slot_id) REFERENCES slot(slot_id) ON DELETE CASCADE +); + + +CREATE INDEX idx_slot_slot_id ON booked_slot(slot_id); +CREATE INDEX idx_slot_cycle ON booked_slot(cycle); \ No newline at end of file diff --git a/services/slots-service/src/main/resources/db/migration/V3__slots_indexes.sql b/services/slots-service/src/main/resources/db/migration/V3__slots_indexes.sql new file mode 100644 index 0000000..9d950d2 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V3__slots_indexes.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_slot_num ON slot(slot_num); +CREATE INDEX idx_slot_dict ON slot(satellite_id, slot_num); \ No newline at end of file diff --git a/services/slots-service/src/main/resources/db/migration/V4__booked_slot_unique.sql b/services/slots-service/src/main/resources/db/migration/V4__booked_slot_unique.sql new file mode 100644 index 0000000..5e2ada9 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V4__booked_slot_unique.sql @@ -0,0 +1 @@ +ALTER TABLE booked_slot ADD CONSTRAINT unique_booked_slot UNIQUE (slot_id, cycle); \ No newline at end of file diff --git a/services/slots-service/src/main/resources/db/migration/V5__booked_requests.sql b/services/slots-service/src/main/resources/db/migration/V5__booked_requests.sql new file mode 100644 index 0000000..6d735f8 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V5__booked_requests.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS booked_slot_request( + booked_slot_request_id BIGSERIAL PRIMARY KEY, + booked_slot_id BIGINT NOT NULL, + request_id VARCHAR(50) NOT NULL, + FOREIGN KEY (booked_slot_id) REFERENCES booked_slot(booked_slot_id) ON DELETE CASCADE, + UNIQUE (booked_slot_id, request_id) +); + + +CREATE INDEX idx_booked_slot_request_slot_id ON booked_slot_request(booked_slot_id); +CREATE INDEX idx_booked_slot_request_request_id ON booked_slot_request(request_id); +CREATE INDEX idx_booked_slot_request_request_id_slot_id ON booked_slot_request(booked_slot_id, request_id); \ No newline at end of file diff --git a/services/slots-service/src/main/resources/db/migration/V6__booked_slot_status.sql b/services/slots-service/src/main/resources/db/migration/V6__booked_slot_status.sql new file mode 100644 index 0000000..7050a0f --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V6__booked_slot_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE booked_slot +ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'BOOKED'; diff --git a/services/slots-service/src/main/resources/db/migration/V7__slot_coords.sql b/services/slots-service/src/main/resources/db/migration/V7__slot_coords.sql new file mode 100644 index 0000000..f44122f --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V7__slot_coords.sql @@ -0,0 +1,2 @@ +ALTER TABLE slot ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE slot ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/services/slots-service/src/main/resources/db/migration/V8__booked_slot_status_lifecycle.sql b/services/slots-service/src/main/resources/db/migration/V8__booked_slot_status_lifecycle.sql new file mode 100644 index 0000000..fd658ae --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V8__booked_slot_status_lifecycle.sql @@ -0,0 +1,21 @@ +UPDATE booked_slot +SET status = 'CANCELED' +WHERE status = 'CANCELLED'; + +UPDATE booked_slot +SET status = 'FAILED' +WHERE status = 'CRASHED'; + +UPDATE booked_slot +SET status = 'FINISHED' +WHERE status = 'COMPLETED'; + +ALTER TABLE booked_slot + DROP CONSTRAINT IF EXISTS chk_booked_slot_status; + +ALTER TABLE booked_slot + ADD CONSTRAINT chk_booked_slot_status + CHECK (status IN ('BOOKED', 'PROCESSED', 'FINISHED', 'CANCELED', 'FAILED', 'FAILED_TIMEOUT')); + +CREATE INDEX IF NOT EXISTS idx_booked_slot_status + ON booked_slot(status); diff --git a/services/slots-service/src/main/resources/db/migration/V9__slot_spatial_fetch_indexes.sql b/services/slots-service/src/main/resources/db/migration/V9__slot_spatial_fetch_indexes.sql new file mode 100644 index 0000000..a184568 --- /dev/null +++ b/services/slots-service/src/main/resources/db/migration/V9__slot_spatial_fetch_indexes.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_slot_satellite_tn_tk ON slot(satellite_id, tn, tk); diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/SlotsServiceApplicationTests.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/SlotsServiceApplicationTests.kt new file mode 100644 index 0000000..dd3b6b5 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/SlotsServiceApplicationTests.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.slots_service + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest( + properties = [ + "spring.main.lazy-initialization=true", + "spring.autoconfigure.exclude=" + ] +) +class SlotsServiceApplicationTests { + + @Test + fun contextLoads() { + } + +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/BookingControllerTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/BookingControllerTest.kt new file mode 100644 index 0000000..2fc8d7d --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/BookingControllerTest.kt @@ -0,0 +1,37 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verifyNoInteractions +import org.springframework.http.MediaType +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import space.nstart.pcp.slots_service.service.SlotService +import kotlin.test.assertTrue + +class BookingControllerTest { + + private val slotService = mock(SlotService::class.java) + private val controller = BookingController().also { + ReflectionTestUtils.setField(it, "slotService", slotService) + } + private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller).build() + + @Test + fun `legacy process endpoint returns gone and does not update booked slots`() { + val result = mockMvc.perform( + post("/api/slots/booking/process") + .contentType(MediaType.APPLICATION_JSON) + .content("[10,20]") + ) + .andExpect(status().isGone) + .andReturn() + + assertTrue(result.resolvedException?.message?.contains("Legacy booked slots HTTP processing is retired") == true) + + verifyNoInteractions(slotService) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SatelliteControllerTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SatelliteControllerTest.kt new file mode 100644 index 0000000..e9d9ee8 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SatelliteControllerTest.kt @@ -0,0 +1,56 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.springframework.http.MediaType +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + +class SatelliteControllerTest { + + private val slotService = mock(SlotService::class.java) + private val controller = SatelliteController().also { + ReflectionTestUtils.setField(it, "slotService", slotService) + } + private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(GlobalExceptionHandler()) + .build() + + @Test + fun `publish initial conditions endpoint delegates to slot service`() { + val time = LocalDateTime.of(2026, 3, 30, 10, 0) + mockMvc.perform( + post("/api/satellite/56756/send-ic") + .param("time", "2026-03-30T10:00:00") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isAccepted) + + verify(slotService).publishInitialConditions(56756L, time, true) + } + + @Test + fun `publish initial conditions endpoint returns bad request when satellite is missing`() { + val time = LocalDateTime.of(2026, 3, 30, 10, 0) + doThrow(CustomValidationException("КА 999 не зарегистрирован")) + .`when`(slotService).publishInitialConditions(999L, time, true) + + mockMvc.perform( + post("/api/satellite/999/send-ic") + .param("time", "2026-03-30T10:00:00") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("КА 999 не зарегистрирован")) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SatelliteIcControllerTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SatelliteIcControllerTest.kt new file mode 100644 index 0000000..6d804d1 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SatelliteIcControllerTest.kt @@ -0,0 +1,147 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler +import space.nstart.pcp.slots_service.service.SatelliteIcService +import java.time.LocalDateTime + +class SatelliteIcControllerTest { + + private val satelliteIcService = mock(SatelliteIcService::class.java) + private val controller = SatelliteIcController(satelliteIcService) + private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(GlobalExceptionHandler()) + .build() + + @Test + fun `get satellite ic delegates to service`() { + `when`(satelliteIcService.bySatelliteId(22L)).thenReturn(sampleIc(22L)) + + mockMvc.perform(get("/api/satellite-ic/22")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.satelliteId").value(22L)) + .andExpect(jsonPath("$.ic.orbPoint.revolution").value(42)) + + verify(satelliteIcService).bySatelliteId(22L) + } + + @Test + fun `create satellite ic delegates to service`() { + `when`(satelliteIcService.create(anySatelliteIc())) + .thenReturn(sampleIc(22L)) + + mockMvc.perform( + post("/api/satellite-ic") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "satelliteId": 22, + "ic": { + "orbPoint": { + "time": "2026-03-20T07:20:00", + "revolution": 42, + "vx": 1.0, + "vy": 2.0, + "vz": 3.0, + "x": 4.0, + "y": 5.0, + "z": 6.0 + }, + "sBall": 0.1, + "f81": 125.0 + } + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.satelliteId").value(22L)) + + verify(satelliteIcService).create(anySatelliteIc()) + } + + @Test + fun `update satellite ic delegates to service`() { + `when`(satelliteIcService.update(eqValue(22L), anyInitialConditions())) + .thenReturn(sampleIc(22L)) + + mockMvc.perform( + put("/api/satellite-ic/22") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "orbPoint": { + "time": "2026-03-20T07:20:00", + "revolution": 42, + "vx": 1.0, + "vy": 2.0, + "vz": 3.0, + "x": 4.0, + "y": 5.0, + "z": 6.0 + }, + "sBall": 0.1, + "f81": 125.0 + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.satelliteId").value(22L)) + + verify(satelliteIcService).update(eqValue(22L), anyInitialConditions()) + } + + @Test + fun `delete satellite ic delegates to service`() { + mockMvc.perform(delete("/api/satellite-ic/22")) + .andExpect(status().isNoContent) + + verify(satelliteIcService).delete(22L) + } + + private fun sampleIc(satelliteId: Long) = SatelliteICDTO( + satelliteId = satelliteId, + ic = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = LocalDateTime.of(2026, 3, 20, 7, 20), + revolution = 42, + vx = 1.0, + vy = 2.0, + vz = 3.0, + x = 4.0, + y = 5.0, + z = 6.0 + ), + sBall = 0.1, + f81 = 125.0 + ) + ) + + private fun anySatelliteIc(): SatelliteICDTO = + any(SatelliteICDTO::class.java) ?: SatelliteICDTO() + + private fun anyInitialConditions(): InitialConditionsDTO = + any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO() + + private fun eqValue(value: Long): Long = eq(value) ?: value +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SlotControllerTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SlotControllerTest.kt new file mode 100644 index 0000000..17d021c --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/SlotControllerTest.kt @@ -0,0 +1,196 @@ +package space.nstart.pcp.slots_service.controller + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + +class SlotControllerTest { + + private val slotService = mock(SlotService::class.java) + private val controller = SlotController().also { + ReflectionTestUtils.setField(it, "slotService", slotService) + } + private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(GlobalExceptionHandler()) + .build() + + @Test + fun `interval endpoint delegates to slot service`() { + val satelliteId = 56756L + val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0) + val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0) + doReturn( + listOf( + SlotDTO( + cycle = 2, + satelliteId = 56756L, + tn = LocalDateTime.of(2026, 4, 20, 1, 0), + tk = LocalDateTime.of(2026, 4, 20, 1, 15), + roll = 12.5, + contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))", + revolution = 44L, + revolutionSign = RevolutionSign.ASC, + slotNumber = 101L + ) + ) + ).`when`(slotService).allByInterval(satelliteId, timeStart, timeStop) + + mockMvc.perform( + get("/api/slots/interval") + .param("satelliteId", satelliteId.toString()) + .param("timeStart", timeStart.toString()) + .param("timeStop", timeStop.toString()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].satelliteId").value(56756L)) + .andExpect(jsonPath("$[0].slotNumber").value(101L)) + + verify(slotService).allByInterval(satelliteId, timeStart, timeStop) + } + + @Test + fun `poly cover endpoint delegates coverage strategy to slot service`() { + val wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0) + val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0) + doReturn(emptyList()).`when`(slotService).polySlots( + wkt, + timeStart, + timeStop, + RevolutionSign.ASC, + true, + null, + listOf(22L), + 12.5, + SlotCoverageStrategy.CONTINUOUS + ) + + mockMvc.perform( + get("/api/slots/poly-cover") + .param("wkt", wkt) + .param("timeStart", timeStart.toString()) + .param("timeStop", timeStop.toString()) + .param("revSign", "ASC") + .param("cov", "true") + .param("satellites", "22") + .param("sun", "12.5") + .param("coverageStrategy", "CONTINUOUS") + ) + .andExpect(status().isOk) + + verify(slotService).polySlots( + wkt, + timeStart, + timeStop, + RevolutionSign.ASC, + true, + null, + listOf(22L), + 12.5, + SlotCoverageStrategy.CONTINUOUS + ) + } + + @Test + fun `request cover endpoint delegates coverage strategy to slot service`() { + val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0) + val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0) + doReturn(emptyList()).`when`(slotService).reqCover( + "req-1", + timeStart, + timeStop, + RevolutionSign.ASC, + true, + listOf(22L), + 12.5, + SlotCoverageStrategy.CONTINUOUS + ) + + mockMvc.perform( + get("/api/slots/request-cover") + .param("requestId", "req-1") + .param("timeStart", timeStart.toString()) + .param("timeStop", timeStop.toString()) + .param("revSign", "ASC") + .param("cov", "true") + .param("satellites", "22") + .param("sun", "12.5") + .param("coverageStrategy", "CONTINUOUS") + ) + .andExpect(status().isOk) + + verify(slotService).reqCover( + "req-1", + timeStart, + timeStop, + RevolutionSign.ASC, + true, + listOf(22L), + 12.5, + SlotCoverageStrategy.CONTINUOUS + ) + } + + @Test + fun `interval endpoint returns bad request on invalid interval`() { + val satelliteId = 56756L + val timeStart = LocalDateTime.of(2026, 4, 21, 0, 0) + val timeStop = LocalDateTime.of(2026, 4, 20, 0, 0) + doThrow(CustomValidationException("Параметр timeStop должен быть больше или равен timeStart")) + .`when`(slotService).allByInterval(satelliteId, timeStart, timeStop) + + mockMvc.perform( + get("/api/slots/interval") + .param("satelliteId", satelliteId.toString()) + .param("timeStart", timeStart.toString()) + .param("timeStop", timeStop.toString()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("Параметр timeStop должен быть больше или равен timeStart")) + } + + @Test + fun `calculation summary endpoint delegates to slot service`() { + doReturn( + listOf( + SlotCalculationSummaryDTO.calculated( + satelliteId = 22L, + slotCount = 12L, + minTime = LocalDateTime.of(2026, 4, 20, 0, 0), + maxTime = LocalDateTime.of(2026, 4, 21, 0, 0), + minRevolution = 100L, + maxRevolution = 112L + ) + ) + ).`when`(slotService).slotCalculationSummaries(listOf(22L, 44L)) + + mockMvc.perform( + get("/api/slots/calculation-summary") + .param("satelliteIds", "22") + .param("satelliteIds", "44") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].satelliteId").value(22L)) + .andExpect(jsonPath("$[0].calculated").value(true)) + .andExpect(jsonPath("$[0].slotCount").value(12L)) + .andExpect(jsonPath("$[0].revolutionInterval").value(12L)) + + verify(slotService).slotCalculationSummaries(listOf(22L, 44L)) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/CoverageStrategyDTOTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/CoverageStrategyDTOTest.kt new file mode 100644 index 0000000..76d70a1 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/CoverageStrategyDTOTest.kt @@ -0,0 +1,79 @@ +package space.nstart.pcp.slots_service.dto + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy +import java.time.LocalDateTime + +class CoverageStrategyDTOTest { + + private val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + private val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0) + private val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0) + + @Test + fun `slot coverage batch request defaults strategy to greedy`() { + val request = SlotCoverageBatchRequestDTO( + timeStart = intervalStart, + timeStop = intervalEnd + ) + + assertEquals(SlotCoverageStrategy.GREEDY, request.coverageStrategy) + } + + @Test + fun `slot coverage batch request copy keeps coverage strategy`() { + val request = SlotCoverageBatchRequestDTO( + timeStart = intervalStart, + timeStop = intervalEnd, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + + assertEquals(SlotCoverageStrategy.CONTINUOUS, request.copy(cov = true).coverageStrategy) + } + + @Test + fun `slot coverage batch request serializes coverage strategy`() { + val json = mapper.writeValueAsString( + SlotCoverageBatchRequestDTO( + timeStart = intervalStart, + timeStop = intervalEnd, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + ) + + assertTrue(json.contains("\"coverageStrategy\":\"CONTINUOUS\"")) + } + + @Test + fun `slot coverage batch request deserializes missing strategy as greedy`() { + val request = mapper.readValue( + """ + { + "items": [], + "timeStart": "2026-03-26T10:00:00", + "timeStop": "2026-03-26T12:00:00" + } + """.trimIndent() + ) + + assertEquals(SlotCoverageStrategy.GREEDY, request.coverageStrategy) + } + + @Test + fun `complex plan process request copy keeps coverage strategy`() { + val request = ComplexPlanProcessRequestDTO( + intervalStart = intervalStart, + intervalEnd = intervalEnd, + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + + assertEquals(SlotCoverageStrategy.CONTINUOUS, request.copy(satelliteId = 22L).coverageStrategy) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolverBenchmarkTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolverBenchmarkTest.kt new file mode 100644 index 0000000..783980d --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolverBenchmarkTest.kt @@ -0,0 +1,88 @@ +package space.nstart.pcp.slots_service.model + +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import java.time.LocalDateTime +import kotlin.system.measureNanoTime + +class ContinuousReqCoverSolverBenchmarkTest { + + private val satellitesById: Map = mapOf( + 22L to TestSatelliteImpl(id = 22L, maxSurveyDuration = 300, mmi = 10), + 23L to TestSatelliteImpl(id = 23L, maxSurveyDuration = 300, mmi = 10), + 24L to TestSatelliteImpl(id = 24L, maxSurveyDuration = 300, mmi = 10) + ) + + @Test + fun `benchmark continuous solver on dense strip scenario`() { + val solver = ContinuousReqCoverSolver( + logger = LoggerFactory.getLogger("ContinuousReqCoverSolverBenchmark"), + satellitesById = satellitesById + ) + + val targetWkt = "POLYGON ((0 0, 40 0, 40 2, 0 2, 0 0))" + val slots = buildScenario() + repeat(3) { solver.select(targetWkt, slots) } + + val elapsedNanos = measureNanoTime { + repeat(20) { + solver.select(targetWkt, slots) + } + } + val elapsedMillis = elapsedNanos / 1_000_000.0 + LoggerFactory.getLogger("ContinuousReqCoverSolverBenchmark") + .info("continuous benchmark: iterations=20, slots={}, elapsedMs={}", slots.size, "%.3f".format(elapsedMillis)) + } + + private fun buildScenario(): List { + val result = mutableListOf() + val baseTime = LocalDateTime.of(2026, 4, 1, 10, 0) + var slotNumber = 1L + + for (satelliteId in listOf(22L, 23L, 24L)) { + for (index in 0 until 60) { + val left = index * 0.6 + val right = left + 1.4 + val timeStart = index * 18L + satelliteId + val timeStop = timeStart + 10L + result += SlotDTO( + cycle = 1L, + satelliteId = satelliteId, + tn = baseTime.plusSeconds(timeStart), + tk = baseTime.plusSeconds(timeStop), + roll = if (satelliteId == 23L) 5.02 else 5.0, + contour = "POLYGON (($left 0, $right 0, $right 2, $left 2, $left 0))", + revolution = 100 + index.toLong(), + revolutionSign = if (satelliteId == 24L && index % 6 < 2) RevolutionSign.DESC else RevolutionSign.ASC, + slotNumber = slotNumber++, + latitude = 0.0, + longitude = left + ) + + if (index % 5 == 0) { + val sliverLeft = left + 1.38 + val sliverRight = sliverLeft + 0.04 + result += SlotDTO( + cycle = 1L, + satelliteId = satelliteId, + tn = baseTime.plusSeconds(timeStart + 8L), + tk = baseTime.plusSeconds(timeStop + 8L), + roll = if (satelliteId == 23L) 5.02 else 5.0, + contour = "POLYGON (($sliverLeft 0, $sliverRight 0, $sliverRight 2, $sliverLeft 2, $sliverLeft 0))", + revolution = 500 + index.toLong(), + revolutionSign = RevolutionSign.ASC, + slotNumber = slotNumber++, + latitude = 0.0, + longitude = sliverLeft + ) + } + } + } + + return result + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolverTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolverTest.kt new file mode 100644 index 0000000..2202677 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/ContinuousReqCoverSolverTest.kt @@ -0,0 +1,313 @@ +package space.nstart.pcp.slots_service.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.locationtech.jts.geom.Geometry +import org.locationtech.jts.io.WKTReader +import org.slf4j.LoggerFactory +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry +import java.time.LocalDateTime + +class ContinuousReqCoverSolverTest { + + private val satellitesById: Map = mapOf( + 22L to TestSatelliteImpl(id = 22L, maxSurveyDuration = 300, mmi = 10), + 23L to TestSatelliteImpl(id = 23L, maxSurveyDuration = 300, mmi = 10) + ) + + private val solver = ContinuousReqCoverSolver( + logger = LoggerFactory.getLogger("ContinuousReqCoverSolverTest"), + satellitesById = satellitesById + ) + + private val greedySolver = OptimalReqCoverSolver( + logger = LoggerFactory.getLogger("ContinuousReqCoverSolverTest.greedy"), + satellitesById = satellitesById + ) + + @Test + fun `continuous solver aggregates overlapping slots into chain-aware candidates`() { + val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java) + mergeMethod.isAccessible = true + + @Suppress("UNCHECKED_CAST") + val chains = mergeMethod.invoke( + solver, + listOf( + slot( + slotNumber = 1, + startOffsetSeconds = 0, + endOffsetSeconds = 30, + contour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 60, + contour = "POLYGON ((1 0, 3 0, 3 1, 1 1, 1 0))" + ), + slot( + slotNumber = 3, + startOffsetSeconds = 120, + endOffsetSeconds = 150, + contour = "POLYGON ((4 0, 5 0, 5 1, 4 1, 4 0))" + ) + ) + ) as List + + assertEquals(2, chains.size) + val slotsField = chains.first().javaClass.getDeclaredField("slots").apply { isAccessible = true } + val representativeField = chains.first().javaClass.getDeclaredField("representative").apply { isAccessible = true } + val firstChainSlots = slotsField.get(chains.first()) as List<*> + val firstRepresentative = representativeField.get(chains.first()) as SlotDTO + + assertEquals(2, firstChainSlots.size) + assertEquals(1L, firstRepresentative.slotNumber) + } + + @Test + fun `start selection uses continuation potential instead of leftmost fragment`() { + val result = solver.select( + targetWkt = "POLYGON ((0 0, 5 0, 5 1, 0 1, 0 0))", + slots = listOf( + slot( + slotNumber = 1, + startOffsetSeconds = 0, + endOffsetSeconds = 10, + contour = "POLYGON ((0 0, 0.4 0, 0.4 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 30, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ), + slot( + slotNumber = 3, + startOffsetSeconds = 40, + endOffsetSeconds = 50, + contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))" + ), + slot( + slotNumber = 4, + startOffsetSeconds = 60, + endOffsetSeconds = 70, + contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))" + ) + ) + ) + + assertFalse(result.isEmpty()) + assertEquals(2L, result.first().slotNumber) + } + + @Test + fun `next selection prefers meaningful gain over tiny touching continuation`() { + val result = solver.select( + targetWkt = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))", + slots = listOf( + slot( + slotNumber = 1, + startOffsetSeconds = 0, + endOffsetSeconds = 10, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 30, + contour = "POLYGON ((0.999 0, 1.001 0, 1.001 1, 0.999 1, 0.999 0))" + ), + slot( + slotNumber = 3, + startOffsetSeconds = 40, + endOffsetSeconds = 50, + contour = "POLYGON ((1.25 0, 2.25 0, 2.25 1, 1.25 1, 1.25 0))" + ) + ) + ) + + assertEquals(listOf(1L, 3L), result.map { it.slotNumber }) + } + + @Test + fun `continuity is evaluated by target-covered geometry rather than full contour`() { + val result = solver.select( + targetWkt = "POLYGON ((0 0, 3 0, 3 1, 0 1, 0 0))", + slots = listOf( + slot( + slotNumber = 1, + startOffsetSeconds = 0, + endOffsetSeconds = 10, + contour = "POLYGON ((0 0, 1 0, 1 2, 0 2, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 30, + contour = "MULTIPOLYGON (((1 1.2, 1.5 1.2, 1.5 2, 1 2, 1 1.2)), ((2 0, 3 0, 3 1, 2 1, 2 0)))" + ), + slot( + slotNumber = 3, + startOffsetSeconds = 40, + endOffsetSeconds = 50, + contour = "POLYGON ((0.9 0, 2 0, 2 1, 0.9 1, 0.9 0))" + ) + ) + ) + + assertEquals(listOf(1L, 3L), result.map { it.slotNumber }) + } + + @Test + fun `same longitude candidate without meaningful gain is rejected`() { + val result = solver.select( + targetWkt = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))", + slots = listOf( + slot( + slotNumber = 1, + startOffsetSeconds = 0, + endOffsetSeconds = 10, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 30, + contour = "POLYGON ((0.8 0, 1 0, 1 1, 0.8 1, 0.8 0))" + ), + slot( + slotNumber = 3, + startOffsetSeconds = 40, + endOffsetSeconds = 50, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ) + ) + ) + + assertEquals(listOf(1L, 3L), result.map { it.slotNumber }) + } + + @Test + fun `same longitude candidate is allowed only when it adds meaningful new coverage`() { + val result = solver.select( + targetWkt = "POLYGON ((0 0, 1 0, 1 2, 0 2, 0 0))", + slots = listOf( + slot( + slotNumber = 1, + startOffsetSeconds = 0, + endOffsetSeconds = 10, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 30, + contour = "POLYGON ((0 1, 1 1, 1 2, 0 2, 0 1))" + ) + ) + ) + + assertEquals(listOf(1L, 2L), result.map { it.slotNumber }) + } + + @Test + fun `continuous coverage remains competitive with greedy on long strip`() { + val target = "POLYGON ((0 0, 6 0, 6 1, 0 1, 0 0))" + val slots = listOf( + slot(1, 0, 10, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + slot(2, 20, 30, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"), + slot(3, 40, 50, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"), + slot(4, 60, 70, contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))"), + slot(5, 80, 90, contour = "POLYGON ((4 0, 5 0, 5 1, 4 1, 4 0))"), + slot(11, 100, 110, contour = "POLYGON ((0.999 0, 1.001 0, 1.001 1, 0.999 1, 0.999 0))"), + slot(12, 120, 130, contour = "POLYGON ((1.999 0, 2.001 0, 2.001 1, 1.999 1, 1.999 0))") + ) + + val greedy = greedySolver.select(target, slots) + val continuous = solver.select(target, slots) + val greedyMetrics = coverageMetrics(target, greedy) + val continuousMetrics = coverageMetrics(target, continuous) + + assertTrue(continuousMetrics.coveragePercent >= greedyMetrics.coveragePercent * 0.95) + assertTrue(continuousMetrics.redundancyArea <= greedyMetrics.redundancyArea + 0.1) + assertTrue(continuous.size <= greedy.size + 1) + } + + @Test + fun `continuous does not collapse on competing branches fixture`() { + val target = "POLYGON ((0 0, 5 0, 5 1, 0 1, 0 0))" + val slots = listOf( + slot(1, 0, 10, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + slot(2, 20, 30, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"), + slot(3, 40, 50, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"), + slot(10, 15, 25, revolutionSign = RevolutionSign.DESC, contour = "POLYGON ((0.8 0, 2.4 0, 2.4 1, 0.8 1, 0.8 0))"), + slot(11, 35, 45, revolutionSign = RevolutionSign.DESC, contour = "POLYGON ((2.4 0, 4.0 0, 4.0 1, 2.4 1, 2.4 0))"), + slot(12, 55, 65, contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))") + ) + + val greedy = greedySolver.select(target, slots) + val continuous = solver.select(target, slots) + val greedyMetrics = coverageMetrics(target, greedy) + val continuousMetrics = coverageMetrics(target, continuous) + + assertTrue(continuousMetrics.coveragePercent >= greedyMetrics.coveragePercent * 0.85) + assertTrue(continuousMetrics.coveragePercent >= 75.0) + } + + private fun coverageMetrics(targetWkt: String, slots: List): CoverageMetrics { + val reader = WKTReader() + val target = LongitudeWrapGeometry.normalizeToContinuous360(reader.read(targetWkt)) + val coveredParts = slots.map { slot -> + val geometry = LongitudeWrapGeometry.alignToReference(reader.read(slot.contour), target) + geometry.intersection(target) + }.filter { !it.isEmpty } + + val union = coveredParts.reduceOrNull(Geometry::union) + val coveredArea = union?.area ?: 0.0 + val totalArea = target.area + val redundancyArea = coveredParts.sumOf { it.area } - coveredArea + + return CoverageMetrics( + coveragePercent = if (totalArea == 0.0) 0.0 else coveredArea * 100.0 / totalArea, + redundancyArea = redundancyArea + ) + } + + private data class CoverageMetrics( + val coveragePercent: Double, + val redundancyArea: Double + ) + + private fun slot( + slotNumber: Long, + startOffsetSeconds: Long, + endOffsetSeconds: Long, + satelliteId: Long = 22L, + cycle: Long = 1L, + roll: Double = 5.0, + revolutionSign: RevolutionSign = RevolutionSign.ASC, + contour: String + ): SlotDTO { + val start = LocalDateTime.of(2026, 3, 30, 10, 0) + return SlotDTO( + cycle = cycle, + satelliteId = satelliteId, + tn = start.plusSeconds(startOffsetSeconds), + tk = start.plusSeconds(endOffsetSeconds), + roll = roll, + contour = contour, + revolution = 101, + revolutionSign = revolutionSign, + slotNumber = slotNumber, + latitude = 0.0, + longitude = 0.0 + ) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/OptimalReqCoverSolverTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/OptimalReqCoverSolverTest.kt new file mode 100644 index 0000000..eb1d725 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/OptimalReqCoverSolverTest.kt @@ -0,0 +1,268 @@ +package space.nstart.pcp.slots_service.model + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import java.time.LocalDateTime + +class OptimalReqCoverSolverTest { + + @Test + fun `mergeSlotsToChains preserves representative coordinates`() { + val solver = OptimalReqCoverSolver( + logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.coordinates"), + satellitesById = emptyMap() + ) + val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java) + mergeMethod.isAccessible = true + val start = LocalDateTime.of(2026, 3, 30, 10, 0) + val slots = listOf( + SlotDTO( + cycle = 1, + satelliteId = 22, + tn = start, + tk = start.plusSeconds(30), + roll = 5.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 101, + revolutionSign = RevolutionSign.ASC, + slotNumber = 1, + latitude = 55.75, + longitude = 37.62 + ), + SlotDTO( + cycle = 1, + satelliteId = 22, + tn = start.plusSeconds(20), + tk = start.plusSeconds(60), + roll = 5.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 101, + revolutionSign = RevolutionSign.ASC, + slotNumber = 2, + latitude = 56.0, + longitude = 38.0 + ) + ) + + @Suppress("UNCHECKED_CAST") + val chains = mergeMethod.invoke(solver, slots) as List + val representativeField = chains.single().javaClass.getDeclaredField("representative") + representativeField.isAccessible = true + val representative = representativeField.get(chains.single()) as SlotDTO + + assertEquals(55.75, representative.latitude) + assertEquals(37.62, representative.longitude) + } + + @Test + fun `mergeSlotsToChains merges when time overlaps and geometry intersects`() { + val solver = OptimalReqCoverSolver( + logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.mergeAllowed"), + satellitesById = emptyMap() + ) + val chains = mergeChains( + solver, + listOf( + slot( + startOffsetSeconds = 0, + endOffsetSeconds = 30, + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 60, + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ) + ) + ) + + assertEquals(1, chains.size) + val representative = representative(chains.single()) + assertTrue(representative.contour.startsWith("POLYGON")) + assertFalse(representative.contour.startsWith("MULTIPOLYGON")) + } + + @Test + fun `mergeSlotsToChains keeps chains separate when time overlaps but geometry does not intersect`() { + val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.noGeometry") as Logger + val logs = captureLogs(logger) { + val solver = OptimalReqCoverSolver( + logger = logger, + satellitesById = emptyMap() + ) + val chains = mergeChains( + solver, + listOf( + slot( + startOffsetSeconds = 0, + endOffsetSeconds = 30, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 60, + contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))" + ) + ) + ) + + assertEquals(2, chains.size) + } + + assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") }) + } + + @Test + fun `mergeSlotsToChains keeps chains separate when contours only touch by boundary`() { + val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.touchingBoundary") as Logger + val logs = captureLogs(logger) { + val solver = OptimalReqCoverSolver( + logger = logger, + satellitesById = emptyMap() + ) + val chains = mergeChains( + solver, + listOf( + slot( + startOffsetSeconds = 0, + endOffsetSeconds = 30, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 60, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ) + ) + ) + + assertEquals(2, chains.size) + } + + assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") }) + } + + @Test + fun `mergeSlotsToChains does not merge when intervals do not overlap`() { + val solver = OptimalReqCoverSolver( + logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.noTimeOverlap"), + satellitesById = emptyMap() + ) + val chains = mergeChains( + solver, + listOf( + slot( + startOffsetSeconds = 0, + endOffsetSeconds = 30, + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 31, + endOffsetSeconds = 60, + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ) + ) + ) + + assertEquals(2, chains.size) + } + + @Test + fun `mergeSlotsToChains skips merge when union returns multipolygon`() { + val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.multiPolygon") as Logger + val logs = captureLogs(logger) { + val solver = OptimalReqCoverSolver( + logger = logger, + satellitesById = emptyMap() + ) + val method = solver.javaClass.getDeclaredMethod( + "unionContoursIfPolygon", + String::class.java, + String::class.java, + SlotDTO::class.java, + SlotDTO::class.java + ) + method.isAccessible = true + + val result = method.invoke( + solver, + "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", + slot( + startOffsetSeconds = 0, + endOffsetSeconds = 30, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + slot( + slotNumber = 2, + startOffsetSeconds = 20, + endOffsetSeconds = 60, + contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))" + ) + ) + + assertEquals(null, result) + } + + assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") }) + } + + private fun mergeChains(solver: OptimalReqCoverSolver, slots: List): List { + val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java) + mergeMethod.isAccessible = true + @Suppress("UNCHECKED_CAST") + return mergeMethod.invoke(solver, slots) as List + } + + private fun representative(chain: Any): SlotDTO { + val representativeField = chain.javaClass.getDeclaredField("representative") + representativeField.isAccessible = true + return representativeField.get(chain) as SlotDTO + } + + private fun slot( + slotNumber: Long = 1, + startOffsetSeconds: Long, + endOffsetSeconds: Long, + contour: String + ): SlotDTO { + val start = LocalDateTime.of(2026, 3, 30, 10, 0) + return SlotDTO( + cycle = 1, + satelliteId = 22, + tn = start.plusSeconds(startOffsetSeconds), + tk = start.plusSeconds(endOffsetSeconds), + roll = 5.0, + contour = contour, + revolution = 101, + revolutionSign = RevolutionSign.ASC, + slotNumber = slotNumber, + latitude = 55.75, + longitude = 37.62 + ) + } + + private fun captureLogs(logger: Logger, block: () -> Unit): List { + val appender = ListAppender() + appender.start() + logger.addAppender(appender) + try { + block() + return appender.list.toList() + } finally { + logger.detachAppender(appender) + } + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImplTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImplTest.kt new file mode 100644 index 0000000..6380f3d --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImplTest.kt @@ -0,0 +1,68 @@ +package space.nstart.pcp.slots_service.repository + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verifyNoInteractions +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.util.ReflectionTestUtils +import java.sql.Timestamp +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SlotRepositoryImplTest { + + @Test + fun `findTargetSlotRows returns empty result without hitting jdbc for empty request batch`() { + val jdbcTemplate = mock(JdbcTemplate::class.java) + val repository = SlotRepositoryImpl(jdbcTemplate) + + val result = repository.findTargetSlotRows(emptyList(), chunkSize = 256) + + assertTrue(result.isEmpty()) + verifyNoInteractions(jdbcTemplate) + } + + @Test + fun `batch spatial fetch sql filters by satellite time bbox and exact geometry without giant in`() { + val repository = SlotRepositoryImpl(mock(JdbcTemplate::class.java)) + + val sql = ReflectionTestUtils.invokeMethod(repository, "buildBatchSpatialFetchSql", 2) + val params = ReflectionTestUtils.invokeMethod>( + repository, + "buildBatchSpatialFetchParams", + listOf( + BatchCoverageSearchRequest( + targetId = 1L, + satelliteId = 22L, + windowStart = LocalDateTime.of(2026, 4, 1, 10, 0), + windowStop = LocalDateTime.of(2026, 4, 1, 12, 0), + polygonWkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + BatchCoverageSearchRequest( + targetId = 2L, + satelliteId = 33L, + windowStart = LocalDateTime.of(2026, 4, 2, 10, 0), + windowStop = LocalDateTime.of(2026, 4, 2, 12, 0), + polygonWkt = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))" + ) + ) + ) + + requireNotNull(sql) + requireNotNull(params) + assertTrue(sql.contains("slot.satellite_id = search.satellite_id")) + assertTrue(sql.contains("slot.tn >= search.window_start")) + assertTrue(sql.contains("slot.tk <= search.window_stop")) + assertTrue(sql.contains("slot.contour_geom && search.search_geom")) + assertTrue(sql.contains("st_intersects(search.search_geom, slot.contour_geom)")) + assertFalse(sql.contains("slot.satellite_id in")) + assertFalse(sql.contains("slot_id in")) + assertEquals(10, params.size) + assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 1, 10, 0)), params[2]) + assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 1, 12, 0)), params[3]) + assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 2, 10, 0)), params[7]) + assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 2, 12, 0)), params[8]) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotStatusServiceTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotStatusServiceTest.kt new file mode 100644 index 0000000..207d8bf --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotStatusServiceTest.kt @@ -0,0 +1,80 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import java.time.LocalDateTime + +class BookedSlotStatusServiceTest { + + private val repository = mock(BookedSlotsRepository::class.java) + private val service = BookedSlotStatusService(repository) + + @Test + fun `updates booked slots from booked to processed`() { + val entity = bookedSlotEntity(id = 10L, status = BookedSlotStatus.BOOKED) + `when`(repository.findAllByBookedSlotIdIn(listOf(10L))).thenReturn(listOf(entity)) + + val updated = service.updateBookedSlotsStatus(listOf(10L), BookedSlotStatus.PROCESSED, "test") + + assertEquals(1, updated) + assertEquals(BookedSlotStatus.PROCESSED.name, entity.status) + verify(repository).saveAll(listOf(entity)) + } + + @Test + fun `updates processed slots to terminal state`() { + val entity = bookedSlotEntity(id = 15L, status = BookedSlotStatus.PROCESSED) + `when`(repository.findAllByBookedSlotIdIn(listOf(15L))).thenReturn(listOf(entity)) + + val updated = service.updateBookedSlotsStatus(listOf(15L), BookedSlotStatus.FINISHED, "test") + + assertEquals(1, updated) + assertEquals(BookedSlotStatus.FINISHED.name, entity.status) + verify(repository).saveAll(listOf(entity)) + } + + @Test + fun `same status delivery is idempotent`() { + val entity = bookedSlotEntity(id = 20L, status = BookedSlotStatus.PROCESSED) + `when`(repository.findAllByBookedSlotIdIn(listOf(20L))).thenReturn(listOf(entity)) + + val updated = service.updateBookedSlotsStatus(listOf(20L), BookedSlotStatus.PROCESSED, "duplicate") + + assertEquals(0, updated) + verify(repository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()) + } + + @Test + fun `invalid transition is ignored`() { + val entity = bookedSlotEntity(id = 25L, status = BookedSlotStatus.FAILED_TIMEOUT) + `when`(repository.findAllByBookedSlotIdIn(listOf(25L))).thenReturn(listOf(entity)) + + val updated = service.updateBookedSlotsStatus(listOf(25L), BookedSlotStatus.PROCESSED, "invalid") + + assertEquals(0, updated) + assertEquals(BookedSlotStatus.FAILED_TIMEOUT.name, entity.status) + verify(repository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()) + } + + private fun bookedSlotEntity(id: Long, status: BookedSlotStatus): BookedSlotEntity = + BookedSlotEntity( + bookedSlotId = id, + slot = SlotEntity( + slotId = 100L + id, + slotNum = id, + satelliteId = 77L, + tn = LocalDateTime.of(2026, 3, 31, 10, 0), + tk = LocalDateTime.of(2026, 3, 31, 10, 10) + ), + cycle = 0, + status = status.name + ) +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeoutSchedulerTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeoutSchedulerTest.kt new file mode 100644 index 0000000..2c2ff5a --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotTimeoutSchedulerTest.kt @@ -0,0 +1,71 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.slots_service.configuration.BookedSlotsTimeoutProperties +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +class BookedSlotTimeoutSchedulerTest { + + private val repository = mock(BookedSlotsRepository::class.java) + private val statusService = mock(BookedSlotStatusUpdater::class.java) + private val timeResolver = mock(BookedSlotTimeResolver::class.java) + private val properties = BookedSlotsTimeoutProperties( + timeoutCheckInterval = Duration.ofHours(1), + timeoutThreshold = Duration.ofHours(1) + ) + private val clock = Clock.fixed(Instant.parse("2026-03-31T12:00:00Z"), ZoneOffset.UTC) + private val scheduler = BookedSlotTimeoutScheduler(repository, statusService, timeResolver, properties, clock) + + @Test + fun `scheduler marks stale booked slots as failed timeout`() { + val booked = bookedSlotEntity(10L) + `when`(repository.findAllByStatus(BookedSlotStatus.BOOKED.name)).thenReturn(listOf(booked)) + `when`(timeResolver.resolveBookedSlotStartTime(booked)).thenReturn(LocalDateTime.of(2026, 3, 31, 9, 30)) + + scheduler.markTimedOutBookedSlots() + + verify(statusService).updateBookedSlotsStatus( + listOf(10L), + BookedSlotStatus.FAILED_TIMEOUT, + "timeout-scheduler" + ) + } + + @Test + fun `scheduler ignores not yet timed out slots`() { + val booked = bookedSlotEntity(20L) + `when`(repository.findAllByStatus(BookedSlotStatus.BOOKED.name)).thenReturn(listOf(booked)) + `when`(timeResolver.resolveBookedSlotStartTime(booked)).thenReturn(LocalDateTime.of(2026, 3, 31, 11, 30)) + + scheduler.markTimedOutBookedSlots() + + verifyNoInteractions(statusService) + } + + private fun bookedSlotEntity(id: Long): BookedSlotEntity = + BookedSlotEntity( + bookedSlotId = id, + slot = SlotEntity( + slotId = id + 100, + slotNum = id, + satelliteId = 77L, + tn = LocalDateTime.of(2026, 3, 31, 8, 0), + tk = LocalDateTime.of(2026, 3, 31, 8, 10) + ), + cycle = 0, + status = BookedSlotStatus.BOOKED.name + ) +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotsKafkaListenerTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotsKafkaListenerTest.kt new file mode 100644 index 0000000..cf79355 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BookedSlotsKafkaListenerTest.kt @@ -0,0 +1,68 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.ObjectProvider +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.time.LocalDateTime +import java.util.UUID + +class BookedSlotsKafkaListenerTest { + + private val objectMapper = jacksonObjectMapper().findAndRegisterModules() + private val bookedSlotStatusService = RecordingBookedSlotStatusUpdater() + private val listener = BookedSlotsKafkaListener(SingleObjectProvider(objectMapper), bookedSlotStatusService) + + @Test + fun `consumer forwards booked slot status event to status service`() { + val payload = objectMapper.writeValueAsString( + KafkaMessage( + type = PcpKafkaEvent.BookedSlotsStatusChangedEvent, + data = BookedSlotsStatusChangedEvent( + bookedSlotIds = listOf(10L, 20L), + status = BookedSlotStatus.PROCESSED, + occurredAt = LocalDateTime.of(2026, 3, 31, 12, 0), + sourceService = "pcp-mission-planing-service", + missionId = UUID.randomUUID(), + modeId = 100L, + satelliteId = 77L + ) + ) + ) + + listener.consume(payload) + + assertEquals(listOf(10L, 20L), bookedSlotStatusService.lastBookedSlotIds) + assertEquals(BookedSlotStatus.PROCESSED, bookedSlotStatusService.lastStatus) + assertTrue(bookedSlotStatusService.lastContext.startsWith("kafka:pcp-mission-planing-service:")) + } + + private class RecordingBookedSlotStatusUpdater : BookedSlotStatusUpdater { + var lastBookedSlotIds: List = emptyList() + var lastStatus: BookedSlotStatus? = null + var lastContext: String = "" + + override fun updateBookedSlotsStatus( + bookedSlotIds: List, + targetStatus: BookedSlotStatus, + context: String + ): Int { + lastBookedSlotIds = bookedSlotIds + lastStatus = targetStatus + lastContext = context + return bookedSlotIds.size + } + } + + private class SingleObjectProvider(private val value: T) : ObjectProvider { + override fun getObject(vararg args: Any?): T = value + override fun getIfAvailable(): T = value + override fun getIfUnique(): T = value + override fun getObject(): T = value + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/EarthServiceTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/EarthServiceTest.kt new file mode 100644 index 0000000..3f0f307 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/EarthServiceTest.kt @@ -0,0 +1,317 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.ObjectProvider +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import java.net.URI +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class EarthServiceTest { + @Test + fun `reqs calls v1 requests list endpoint`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000001") + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + requestsPageResponse(requestId), + requestDetailsResponse(requestId), + ) + ) + + service.reqs().collectList().block() + + assertEquals("/v1/requests", requestedUris.first().path) + assertEquals("page=0&size=500", requestedUris.first().query) + assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" }) + } + + @Test + fun `reqs unwraps list items and maps request details to current request dto`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002") + val service = earthService( + responses = mutableListOf( + requestsPageResponse(requestId, importance = 3.0), + requestDetailsResponse( + id = requestId, + importance = 7.5, + geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + ), + ) + ) + + val request = service.reqs().collectList().block()!!.single() + + assertEquals(requestId, request.requestId) + assertEquals("Full request", request.name) + assertEquals(7.5, request.importance) + assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", request.contour) + } + + @Test + fun `reqs reads all pages from v1 requests`() { + val firstRequestId = UUID.fromString("00000000-0000-0000-0000-000000000003") + val secondRequestId = UUID.fromString("00000000-0000-0000-0000-000000000004") + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + requestsPageResponse(firstRequestId, page = 0, totalPages = 2), + requestsPageResponse(secondRequestId, page = 1, totalPages = 2), + requestDetailsResponse(firstRequestId, geometry = "POLYGON EMPTY"), + requestDetailsResponse(secondRequestId, geometry = "POLYGON EMPTY"), + ) + ) + + val requests = service.reqs().collectList().block()!! + + assertEquals(listOf(firstRequestId, secondRequestId), requests.map { request -> request.requestId }) + assertEquals( + listOf("page=0&size=500", "page=1&size=500"), + requestedUris.filter { uri -> uri.path == "/v1/requests" }.map { uri -> uri.query }, + ) + } + + @Test + fun `reqs loads full request details for contour`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000005") + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + requestsPageResponse(requestId, name = "Summary request"), + requestDetailsResponse( + id = requestId, + name = "Detailed request", + geometry = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))", + ), + ) + ) + + val request = service.reqs().collectList().block()!!.single() + + assertEquals("/v1/requests/$requestId", requestedUris[1].path) + assertEquals("Detailed request", request.name) + assertEquals("POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))", request.contour) + } + + @Test + fun `reqcells calls v1 request with cells and maps request and cell contours`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000006") + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + requestWithCellsResponse(requestId), + ) + ) + + val response = service.reqcells(requestId.toString())!! + + assertEquals("/v1/requests/$requestId/with-cells", requestedUris.single().path) + assertFalse(requestedUris.any { uri -> uri.path.contains("/api/requests/by-number") }) + assertEquals(requestId, response.request.requestId) + assertEquals("Request with cells", response.request.name) + assertEquals("POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))", response.request.contour) + assertEquals(123, response.cells.single().id) + assertEquals(123, response.cells.single().num) + assertEquals(10.0, response.cells.single().importance) + assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", response.cells.single().contour) + } + + @Test + fun `cells calls v1 cells with min importance and maps wrapper items`() { + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + cellsPageResponse(), + ) + ) + + val cells = service.cells(4.5).toList() + + assertEquals("/v1/cells", requestedUris.single().path) + assertEquals("minImportance=4.5&page=0&size=500", requestedUris.single().query) + assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/by-importance" }) + assertFalse(requestedUris.any { uri -> uri.query?.contains("countLat") == true }) + assertFalse(requestedUris.any { uri -> uri.query?.contains("countLong") == true }) + assertEquals(1, cells.single().id) + assertEquals(1, cells.single().num) + assertEquals(55.5, cells.single().latitude) + assertEquals(37.5, cells.single().longitude) + assertEquals(4.5, cells.single().importance) + assertEquals("POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))", cells.single().contour) + } + + @Test + fun `cells reads all pages from v1 cells`() { + val requestedUris = mutableListOf() + val service = earthService( + requestedUris = requestedUris, + responses = mutableListOf( + cellsPageResponse(cellNum = 1, page = 0, totalPages = 2), + cellsPageResponse(cellNum = 2, page = 1, totalPages = 2), + ) + ) + + val cells = service.cells(9.0).toList() + + assertEquals(listOf(1L, 2L), cells.map { cell -> cell.num }) + assertEquals( + listOf("minImportance=9.0&page=0&size=500", "minImportance=9.0&page=1&size=500"), + requestedUris.filter { uri -> uri.path == "/v1/cells" }.map { uri -> uri.query }, + ) + } + + private fun earthService( + requestedUris: MutableList = mutableListOf(), + responses: MutableList, + ): EarthService { + val builder = WebClient.builder() + .exchangeFunction { request -> + requestedUris += request.url() + Mono.just(responses.removeAt(0)) + } + return EarthService(provider(builder), url = "http://pcp-request-service") + } + + private fun requestsPageResponse( + id: UUID, + page: Int = 0, + totalPages: Int = 1, + importance: Double = 1.0, + name: String = "Summary request", + ): ClientResponse = + jsonResponse( + """ + { + "items": [ + { + "id": "$id", + "name": "$name", + "status": "ACTIVE", + "surveyType": "OPTICS", + "importance": $importance, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T00:00:00Z", + "kpp": [1], + "highPriorityTransmit": false, + "coveragePercent": 0.0, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ], + "page": $page, + "size": 500, + "totalItems": $totalPages, + "totalPages": $totalPages + } + """.trimIndent() + ) + + private fun requestDetailsResponse( + id: UUID, + name: String = "Full request", + importance: Double = 1.0, + geometry: String = "POLYGON EMPTY", + ): ClientResponse = + jsonResponse( + """ + { + "id": "$id", + "name": "$name", + "status": "ACTIVE", + "surveyType": "OPTICS", + "geometry": "$geometry", + "importance": $importance, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T00:00:00Z", + "kpp": [1], + "highPriorityTransmit": false, + "coverage": {"currentPercent": 0.0}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + """.trimIndent() + ) + + private fun requestWithCellsResponse(id: UUID): ClientResponse = + jsonResponse( + """ + { + "request": { + "id": "$id", + "name": "Request with cells", + "status": "ACTIVE", + "surveyType": "OPTICS", + "geometry": "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))", + "importance": 7.0, + "beginDateTime": "2026-01-01T00:00:00Z", + "endDateTime": "2026-01-31T00:00:00Z", + "kpp": [1], + "highPriorityTransmit": false, + "coverage": {"currentPercent": 0.0}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + }, + "cells": [ + { + "cellNum": 123, + "coveragePercent": 42.5, + "importance": 10.0, + "contour": "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))" + } + ] + } + """.trimIndent() + ) + + private fun cellsPageResponse( + cellNum: Long = 1, + page: Int = 0, + totalPages: Int = 1, + importance: Double = 4.5, + ): ClientResponse = + jsonResponse( + """ + { + "items": [ + { + "cellNum": $cellNum, + "latitude": 55.5, + "longitude": 37.5, + "importance": $importance, + "contour": "POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))" + } + ], + "page": $page, + "size": 500, + "totalItems": $totalPages, + "totalPages": $totalPages + } + """.trimIndent() + ) + + private fun jsonResponse(body: String): ClientResponse = + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(body) + .build() + + private fun provider(builder: WebClient.Builder): ObjectProvider = + object : ObjectProvider { + override fun getObject(vararg args: Any?): WebClient.Builder = builder + override fun getObject(): WebClient.Builder = builder + override fun getIfAvailable(): WebClient.Builder = builder + override fun getIfUnique(): WebClient.Builder = builder + override fun iterator(): MutableIterator = mutableListOf(builder).iterator() + } +} 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 new file mode 100644 index 0000000..4bd5259 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt @@ -0,0 +1,122 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +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.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OccupiedIntervalPrefilterTest { + + @Test + fun `different roll blocks candidate inside occupied mmi window`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val candidate = candidateSlot(roll = 12.0, startOffsetSeconds = 44, endOffsetSeconds = 70) + val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40)) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertTrue(filtered.isEmpty()) + } + + @Test + fun `same roll continuation is allowed when merged duration fits duration max`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 44, endOffsetSeconds = 70) + val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40)) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertEquals(listOf(candidate), filtered) + } + + @Test + fun `same roll continuation is rejected when merged duration exceeds duration max`() { + val satellite = testSatellite(maxSurveyDuration = 60, mmi = 5) + val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 44, endOffsetSeconds = 70) + val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40)) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertTrue(filtered.isEmpty()) + } + + @Test + fun `occupied intervals with same roll normalize into one chain`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val normalized = OccupiedIntervalPrefilter.normalize( + occupiedIntervals = listOf( + occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40), + occupiedInterval(roll = 10.0, startOffsetSeconds = 43, endOffsetSeconds = 60) + ), + satellite = satellite + ) + + assertEquals(1, normalized.size) + assertEquals(baseTime().plusSeconds(0), normalized.single().startTime) + assertEquals(baseTime().plusSeconds(60), normalized.single().endTime) + } + + @Test + fun `candidate can bridge two same-roll occupied chains within duration max`() { + val satellite = testSatellite(maxSurveyDuration = 90, mmi = 5) + val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 24, endOffsetSeconds = 46) + val occupied = OccupiedIntervalPrefilter.normalize( + occupiedIntervals = listOf( + occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 20), + occupiedInterval(roll = 10.0, startOffsetSeconds = 50, endOffsetSeconds = 70) + ), + satellite = satellite + ) + + 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( + slots: List, + bookedSlots: List, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sign: RevolutionSign?, + states: List? + ): List = emptyList() + } + + private fun occupiedInterval( + roll: Double, + startOffsetSeconds: Long, + endOffsetSeconds: Long + ) = OccupiedIntervalDTO( + satelliteId = 22, + startTime = baseTime().plusSeconds(startOffsetSeconds), + endTime = baseTime().plusSeconds(endOffsetSeconds), + roll = roll, + source = "TEST" + ) + + private fun candidateSlot( + roll: Double, + startOffsetSeconds: Long, + endOffsetSeconds: Long + ) = 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))" + ) + + private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0) +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedServiceTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedServiceTest.kt new file mode 100644 index 0000000..a69e8cd --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedServiceTest.kt @@ -0,0 +1,35 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import space.nstart.pcp.slots_service.repository.SatelliteIcRepository +import space.nstart.pcp.slots_service.repository.SlotRepository + +class SatelliteDeletedServiceTest { + + @Test + fun `deleteSatelliteData removes slots and initial conditions`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + val satelliteIcRepository = mock(SatelliteIcRepository::class.java) + val service = SatelliteDeletedService(slotRepository, bookedSlotsRepository, satelliteIcRepository) + + `when`(bookedSlotsRepository.countBySlot_SatelliteId(56756L)).thenReturn(3L) + `when`(slotRepository.countBySatelliteId(56756L)).thenReturn(10L) + `when`(slotRepository.deleteBySatId(56756L)).thenReturn(10) + `when`(satelliteIcRepository.deleteBySatelliteId(56756L)).thenReturn(1) + + val summary = service.deleteSatelliteData(56756L) + + assertEquals(56756L, summary.satelliteId) + assertEquals(10, summary.slotsDeleted) + assertEquals(3L, summary.bookedSlotsDeletedByCascade) + assertEquals(1, summary.initialConditionsDeleted) + verify(slotRepository).deleteBySatId(56756L) + verify(satelliteIcRepository).deleteBySatelliteId(56756L) + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcKafkaPublisherTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcKafkaPublisherTest.kt new file mode 100644 index 0000000..8214ba7 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SatelliteIcKafkaPublisherTest.kt @@ -0,0 +1,72 @@ +package space.nstart.pcp.slots_service.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.apache.kafka.clients.producer.ProducerRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.ObjectProvider +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.SendResult +import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.util.concurrent.CompletableFuture + +class SatelliteIcKafkaPublisherTest { + + private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + private val objectMapper = jacksonObjectMapper().findAndRegisterModules() + + @Test + fun `publisher sends satellite initial conditions to kafka with ICRVPlacedEvent header`() { + val publisher = SatelliteIcKafkaPublisher( + kafkaTemplateProvider = SingleObjectProvider(kafkaTemplate), + objectMapperProvider = SingleObjectProvider(objectMapper), + topic = "pcp.tle", + applicationName = "slots-service" + ) + val recordCaptor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor> + `when`(kafkaTemplate.send(recordCaptor.capture())).thenReturn(CompletableFuture.completedFuture(mock(SendResult::class.java) as SendResult)) + + publisher.publish( + SatelliteICDTO( + satelliteId = 56756L, + ic = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = LocalDateTime.of(2026, 4, 16, 12, 0), + revolution = 42L, + vx = 1.0, + vy = 2.0, + vz = 3.0, + x = 4.0, + y = 5.0, + z = 6.0 + ), + sBall = 0.7, + f81 = 140.0 + ) + ) + ) + + verify(kafkaTemplate).send(recordCaptor.value) + assertEquals("pcp.tle", recordCaptor.value.topic()) + assertEquals("56756", recordCaptor.value.key()) + val typeHeader = recordCaptor.value.headers().lastHeader("type") + assertNotNull(typeHeader) + assertEquals("ICRVPlacedEvent", String(typeHeader.value(), StandardCharsets.UTF_8)) + } + + private class SingleObjectProvider(private val value: T) : ObjectProvider { + override fun getObject(vararg args: Any?): T = value + override fun getIfAvailable(): T = value + override fun getIfUnique(): T = value + override fun getObject(): T = value + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceBatchTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceBatchTest.kt new file mode 100644 index 0000000..aab6d5b --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceBatchTest.kt @@ -0,0 +1,471 @@ +package space.nstart.pcp.slots_service.service + +import ballistics.types.InitialConditions +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.springframework.test.util.ReflectionTestUtils +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO +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.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import space.nstart.pcp.slots_service.repository.BatchCoverageSearchRequest +import space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow +import space.nstart.pcp.slots_service.repository.BookedRequestRepository +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import space.nstart.pcp.slots_service.repository.SlotRepository +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import javax.sql.DataSource +import kotlin.test.assertEquals + +class SlotServiceBatchTest { + + private fun anyListValue(): List = anyList() ?: emptyList() + private fun anyLongValue(): Long = anyLong() + private fun anyIntValue(): Int = anyInt() + + @Test + fun `polySlotsBatch uses single batch spatial fetch and keeps input target order`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(defaultSatellite(22L)) + ) + + `when`( + slotRepository.findTargetSlotRows( + anyListValue(), + anyIntValue() + ) + ).thenReturn( + listOf( + row(targetId = 2L, slotId = 1002L, satelliteId = 22L), + row(targetId = 1L, slotId = 1001L, satelliteId = 22L) + ) + ) + `when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())).thenReturn(emptyList()) + + val response = service.polySlotsBatch( + SlotCoverageBatchRequestDTO( + items = listOf( + SlotCoverageTargetDTO(1, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + SlotCoverageTargetDTO(2, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))") + ), + timeStart = java.time.LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = java.time.LocalDateTime.of(2026, 3, 26, 12, 0), + cov = false, + satellites = listOf(22L) + ) + ) + + verify(bookedSlotsRepository, times(1)) + .findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()) + verify(slotRepository, times(1)) + .findTargetSlotRows(anyListValue(), anyIntValue()) + kotlin.test.assertEquals(2, response.size) + kotlin.test.assertEquals(listOf(1L, 2L), response.map { it.targetId }) + } + + @Test + fun `polySlotsBatch handles large spatial batch without secondary slot id fetch`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(defaultSatellite(22L)) + ) + + val rows = (1L..70_000L).map { slotId -> + row(targetId = 1L, slotId = slotId, satelliteId = 22L) + } + + `when`( + slotRepository.findTargetSlotRows( + anyListValue(), + anyIntValue() + ) + ).thenReturn(rows) + `when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = service.polySlotsBatch( + SlotCoverageBatchRequestDTO( + items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")), + timeStart = LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 12, 0), + cov = false, + satellites = listOf(22L) + ) + ) + + kotlin.test.assertEquals(1, response.size) + verify(slotRepository, times(1)) + .findTargetSlotRows(anyListValue(), anyIntValue()) + } + + @Test + fun `polySlotsBatch precomputes prepared slots once per satellite for whole batch`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + + val satellite = CountingSatellite(id = 22L) + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(satellite) + ) + + `when`( + slotRepository.findTargetSlotRows( + anyListValue(), + anyIntValue() + ) + ).thenReturn( + listOf( + row(targetId = 1L, slotId = 1001L, satelliteId = 22L), + row(targetId = 2L, slotId = 1001L, satelliteId = 22L) + ) + ) + `when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = service.polySlotsBatch( + SlotCoverageBatchRequestDTO( + items = listOf( + SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + SlotCoverageTargetDTO(2L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))") + ), + timeStart = LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 12, 0), + cov = false, + satellites = listOf(22L) + ) + ) + + assertEquals(1, satellite.prepareCoverageSlotsCalls) + assertEquals(2, response.size) + assertEquals(listOf(1, 1), response.map { it.slots.size }) + } + + @Test + fun `polySlotsBatch pushes satellite and base time windows into spatial fetch request`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + + val satellite = defaultSatellite( + id = 62138L, + tnCalc = LocalDateTime.of(LocalDate.of(2025, 10, 27), LocalTime.of(0, 29, 23, 162630000)), + cycleRevs = 243, + durationCalc = 16, + maxChainLength = 3 + ) + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(satellite) + ) + val baseStart = satellite.tnCalc.plusHours(2) + val requestTimeStart = baseStart.minusMinutes(1) + val requestTimeStop = baseStart.plusMinutes(11) + val capturedRequests = mutableListOf() + + `when`( + slotRepository.findTargetSlotRows( + anyListValue(), + anyIntValue() + ) + ).thenAnswer { invocation -> + capturedRequests += invocation.getArgument>(0) + listOf(row(targetId = 1L, slotId = 1001L, satelliteId = satellite.id)) + } + `when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + service.polySlotsBatch( + SlotCoverageBatchRequestDTO( + items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")), + timeStart = requestTimeStart, + timeStop = requestTimeStop, + cov = false, + satellites = listOf(satellite.id) + ) + ) + + assertEquals(3, capturedRequests.size) + assertEquals(setOf(satellite.id), capturedRequests.map { it.satelliteId }.toSet()) + assertEquals(setOf(1L), capturedRequests.map { it.targetId }.toSet()) + assertEquals( + setOf( + requestTimeStart to requestTimeStop + ), + capturedRequests.map { it.windowStart to it.windowStop }.toSet() + ) + } + + @Test + fun `polySlotsBatch applies continuous coverage strategy`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(CountingSatellite(22L)) + ) + + stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository) + + val response = service.polySlotsBatch( + SlotCoverageBatchRequestDTO( + items = listOf(SlotCoverageTargetDTO(1L, TARGET_WKT)), + timeStart = LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 12, 0), + cov = true, + satellites = listOf(22L), + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ) + ) + + assertEquals(listOf(1L, 3L), response.single().slots.map { it.slotNumber }) + } + + @Test + fun `polySlots applies continuous coverage strategy in single target path`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(CountingSatellite(22L)) + ) + + stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository) + + val response = service.polySlots( + wkt = TARGET_WKT, + timeStart = LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 12, 0), + sign = null, + cov = true, + sats = listOf(22L), + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ).toList() + + assertEquals(listOf(1L, 3L), response.map { it.slotNumber }) + } + + @Test + fun `reqCover applies continuous coverage strategy`() { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + val earthService = mock(EarthService::class.java) + val service = createService( + slotRepository = slotRepository, + bookedSlotsRepository = bookedSlotsRepository, + catalogSatellites = listOf(CountingSatellite(22L)), + earthService = earthService + ) + + stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository) + doReturn(RequestWithCellsDTO(request = RequestDTO(contour = TARGET_WKT))) + .`when`(earthService).reqcells("req-1") + + val response = service.reqCover( + id = "req-1", + timeStart = LocalDateTime.of(2026, 3, 26, 10, 0), + timeStop = LocalDateTime.of(2026, 3, 26, 12, 0), + sign = null, + cov = true, + sats = listOf(22L), + coverageStrategy = SlotCoverageStrategy.CONTINUOUS + ).toList() + + assertEquals(listOf(1L, 3L), response.map { it.slotNumber }) + } + + private fun createService( + slotRepository: SlotRepository, + bookedSlotsRepository: BookedSlotsRepository, + catalogSatellites: List, + earthService: EarthService = mock(EarthService::class.java) + ): SlotService { + val service = SlotService() + ReflectionTestUtils.setField(service, "slotRepository", slotRepository) + ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository) + ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java)) + ReflectionTestUtils.setField(service, "earthService", earthService) + ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java)) + ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(catalogSatellites)) + return service + } + + private fun testSatelliteCatalogClient(satellites: List) = object : SatelliteCatalogClient { + override fun allSatellites(): List = satellites + + override fun satellites(ids: List): List = + ids.distinct().map { satelliteId -> + satellites.find { it.id == satelliteId } + ?: throw CustomValidationException("КА $satelliteId не зарегистрирован") + } + + override fun satellite(id: Long): AbstractSatellite = + satellites.find { it.id == id } + ?: throw CustomValidationException("КА $id не зарегистрирован") + + override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request + } + + private fun defaultSatellite( + id: Long, + tnCalc: LocalDateTime = LocalDateTime.of(2026, 3, 20, 7, 20), + cycleRevs: Long = 275, + durationCalc: Long = 18, + maxChainLength: Int = 3 + ) = TestSatelliteImpl( + id = id, + cycleRevs = cycleRevs, + tnCalc = tnCalc, + durationCalc = durationCalc, + maxChainLength = maxChainLength + ) + + private fun stubRowsForContinuousCoverage( + slotRepository: SlotRepository, + bookedSlotsRepository: BookedSlotsRepository + ) { + `when`( + slotRepository.findTargetSlotRows( + anyListValue(), + anyIntValue() + ) + ).thenReturn( + listOf( + row( + targetId = 1L, + slotId = 1L, + satelliteId = 22L, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + longitude = 0.0, + tn = LocalDateTime.of(2026, 3, 26, 10, 0, 0), + tk = LocalDateTime.of(2026, 3, 26, 10, 0, 10) + ), + row( + targetId = 1L, + slotId = 2L, + satelliteId = 22L, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", + longitude = 0.0, + tn = LocalDateTime.of(2026, 3, 26, 10, 0, 20), + tk = LocalDateTime.of(2026, 3, 26, 10, 0, 30) + ), + row( + targetId = 1L, + slotId = 3L, + satelliteId = 22L, + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", + longitude = 1.0, + tn = LocalDateTime.of(2026, 3, 26, 10, 0, 40), + tk = LocalDateTime.of(2026, 3, 26, 10, 0, 50) + ) + ) + ) + `when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + } + + private fun row( + targetId: Long, + slotId: Long, + satelliteId: Long, + contour: String = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + longitude: Double = 37.0, + roll: Double = 10.0, + revolutionSign: String = "ASC", + tn: LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0), + tk: LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 10) + ) = BatchCoverageSlotRow( + targetId = targetId, + slotId = slotId, + slotNum = slotId, + cycle = 0L, + satelliteId = satelliteId, + coveringType = 0, + tn = tn, + tk = tk, + roll = roll, + contour = contour, + revolution = 1L, + revolutionSign = revolutionSign, + latitude = 55.0, + longitude = longitude + ) + + private class CountingSatellite(id: Long) : AbstractSatellite( + id = id, + cycleRevs = 243, + tnCalc = LocalDateTime.of(LocalDate.of(2025, 10, 27), LocalTime.of(0, 29, 23, 162630000)), + durationCalc = 16, + maxChainLength = 3, + angles = emptyList(), + ic = InitialConditions() + ) { + var prepareCoverageSlotsCalls: Int = 0 + + override fun prepareCoverageSlots( + slots: List, + bookedSlots: List, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sign: RevolutionSign?, + states: List? + ): List { + prepareCoverageSlotsCalls++ + return slots.map { slot -> + PreparedCoverageSlot( + sourceSlotId = slot.slotId ?: 0L, + slot = SlotDTO( + cycle = slot.cycle, + satelliteId = slot.satelliteId, + tn = slot.tn, + tk = slot.tk, + roll = slot.roll, + contour = slot.contour, + revolution = slot.revolution, + revolutionSign = RevolutionSign.valueOf(slot.revolutionSign), + slotNumber = slot.slotNum, + state = SlotStatus.AVAILABLE, + latitude = slot.latitude, + longitude = slot.longitude + ) + ) + } + } + } + + private companion object { + private const val TARGET_WKT = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))" + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCalcSlotsTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCalcSlotsTest.kt new file mode 100644 index 0000000..8fa72b7 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCalcSlotsTest.kt @@ -0,0 +1,207 @@ +package space.nstart.pcp.slots_service.service + +import ballistics.orbitalPoints.timeStepper.AbstractStepper +import ballistics.types.OrbitalPoint +import ballistics.utils.math.Vector3D +import org.junit.jupiter.api.Test +import org.springframework.test.util.ReflectionTestUtils +import space.nstart.pcp.slots_service.model.Mar +import java.time.LocalDateTime +import kotlin.math.cos +import kotlin.math.sin +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SlotServiceCalcSlotsTest { + + @Test + fun `buildSlotCalculationChunks creates aligned half-open windows without gaps`() { + val service = SlotService() + ReflectionTestUtils.setField(service, "slotCalcChunkDurationSeconds", 20L) + ReflectionTestUtils.setField(service, "slotCalcSourceMarginSeconds", 120L) + + val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0) + val endExclusive = start.plusSeconds(60) + + val chunks = ReflectionTestUtils.invokeMethod>( + service, + "buildSlotCalculationChunks", + start, + endExclusive, + 10L, + 60L + ).orEmpty() + + assertEquals(3, chunks.size) + assertEquals(start, ReflectionTestUtils.getField(chunks[0], "generationStart")) + assertEquals(start.plusSeconds(20), ReflectionTestUtils.getField(chunks[0], "generationEndExclusive")) + assertEquals(start.plusSeconds(20), ReflectionTestUtils.getField(chunks[1], "generationStart")) + assertEquals(start.plusSeconds(40), ReflectionTestUtils.getField(chunks[1], "generationEndExclusive")) + assertEquals(start.plusSeconds(40), ReflectionTestUtils.getField(chunks[2], "generationStart")) + assertEquals(endExclusive, ReflectionTestUtils.getField(chunks[2], "generationEndExclusive")) + assertEquals(5L, ReflectionTestUtils.getField(chunks[0], "generationStepSeconds")) + + val sourceWindowStartSeconds = ReflectionTestUtils.getField(chunks[0], "sourceWindowStartSeconds") as Double + val sourceWindowEndSeconds = ReflectionTestUtils.getField(chunks[0], "sourceWindowEndSeconds") as Double + assertTrue(sourceWindowStartSeconds < ballistics.utils.fromDateTime(start)) + assertTrue(sourceWindowEndSeconds > ballistics.utils.fromDateTime(start.plusSeconds(30))) + } + + @Test + fun `drainReadyChunkResults preserves deterministic chunk order`() { + val service = SlotService() + val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0) + val pending = linkedMapOf( + 2 to pendingChunk(2, start.plusSeconds(40), start.plusSeconds(60)), + 0 to pendingChunk(0, start, start.plusSeconds(20)), + 1 to pendingChunk(1, start.plusSeconds(20), start.plusSeconds(40)) + ) + + val drained = ReflectionTestUtils.invokeMethod, Int>>( + service, + "drainReadyChunkResults", + pending, + 0 + )!! + + assertEquals( + listOf(0, 1, 2), + drained.first.map { ReflectionTestUtils.getField(ReflectionTestUtils.getField(it, "completion")!!, "index") } + ) + assertEquals(3, drained.second) + assertTrue(pending.isEmpty()) + } + + @Test + fun `prepared orbital window is reused across multiple contours`() { + val service = SlotService() + val stepper = CountingStepper() + val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0) + val endInclusive = start.plusSeconds(10) + + val window = prepareOrbitalWindow(service, stepper, start, endInclusive) + + val sampledPoints = ReflectionTestUtils.getField(window, "points") as List<*> + assertEquals(3, sampledPoints.size) + assertEquals(3, stepper.requestedTimes.size) + + val firstContour = contourFromPreparedWindow(service, window, 20.0, 2.0) + val secondContour = contourFromPreparedWindow(service, window, 24.0, 2.0) + + assertTrue(firstContour.isNotBlank()) + assertTrue(secondContour.isNotBlank()) + assertEquals(3, stepper.requestedTimes.size) + assertEquals( + listOf(5.0, 5.0), + stepper.requestedTimes.zipWithNext { left, right -> right - left } + ) + } + + @Test + fun `prepareOrbitalWindow reuses initial tn point when provided`() { + val service = SlotService() + val stepper = CountingStepper() + val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0) + val endInclusive = start.plusSeconds(10) + + val initialPoint = stepper.calculate(ballistics.utils.fromDateTime(start)) + val callsAfterInitialPoint = stepper.requestedTimes.size + + val window = prepareOrbitalWindow(service, stepper, start, endInclusive, initialPoint) + + val sampledPoints = ReflectionTestUtils.getField(window, "points") as List<*> + assertEquals(3, sampledPoints.size) + assertEquals(callsAfterInitialPoint + 2, stepper.requestedTimes.size) + } + + private fun pendingChunk( + index: Int, + start: LocalDateTime, + endExclusive: LocalDateTime + ): Any { + val completionClass = Class.forName( + "space.nstart.pcp.slots_service.service.SlotService\$SlotCalculationChunkComplete" + ) + val completionConstructor = completionClass.declaredConstructors.single { it.parameterCount == 11 } + completionConstructor.isAccessible = true + val completion = completionConstructor.newInstance( + index, + 10L, + start, + endExclusive, + 1, + 1, + 5L, + 2L, + 3L, + 1, + 2 + ) + + val pendingChunkClass = Class.forName( + "space.nstart.pcp.slots_service.service.SlotService\$SlotCalculationPendingChunk" + ) + val pendingChunkConstructor = pendingChunkClass.declaredConstructors.single { it.parameterCount == 2 } + pendingChunkConstructor.isAccessible = true + return pendingChunkConstructor.newInstance( + mutableListOf(listOf(Mar(sat = 1L, tn = start, tk = endExclusive))), + completion + ) + } + + private fun prepareOrbitalWindow( + service: SlotService, + stepper: CountingStepper, + start: LocalDateTime, + endInclusive: LocalDateTime, + initialPoint: OrbitalPoint? = null + ): Any { + val method = SlotService::class.java.getDeclaredMethod( + "prepareOrbitalWindow", + AbstractStepper::class.java, + LocalDateTime::class.java, + LocalDateTime::class.java, + OrbitalPoint::class.java + ) + method.isAccessible = true + return method.invoke(service, stepper, start, endInclusive, initialPoint)!! + } + + private fun contourFromPreparedWindow( + service: SlotService, + window: Any, + roll: Double, + capture: Double + ): String { + val preparedWindowClass = Class.forName( + "space.nstart.pcp.slots_service.service.SlotService\$PreparedOrbitalWindow" + ) + val method = SlotService::class.java.getDeclaredMethod( + "contour", + preparedWindowClass, + java.lang.Double.TYPE, + java.lang.Double.TYPE + ) + method.isAccessible = true + return method.invoke(service, window, roll, capture) as String + } + + private class CountingStepper : AbstractStepper { + val requestedTimes = mutableListOf() + + override fun calculate(t: Double): OrbitalPoint { + requestedTimes += t + val phase = t / 600.0 + return OrbitalPoint( + t, + 42, + Vector3D(7_000_000.0 * cos(phase), 7_000_000.0 * sin(phase), 1_000_000.0), + Vector3D(-7_500.0 * sin(phase), 7_500.0 * cos(phase), 10.0) + ) + } + + override fun calculate(t: Double, prev: OrbitalPoint): OrbitalPoint = calculate(t) + + override fun clear() = Unit + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceChainAggregationTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceChainAggregationTest.kt new file mode 100644 index 0000000..e1d0f2c --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceChainAggregationTest.kt @@ -0,0 +1,264 @@ +package space.nstart.pcp.slots_service.service + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +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.SurveySlotDTO +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +class SlotServiceChainAggregationTest { + + private val service = SlotService() + private val method = service.javaClass.getDeclaredMethod( + "buildSurveyChains", + Class.forName("space.nstart.pcp.slots_service.model.satellite.AbstractSatellite"), + List::class.java, + LocalDateTime::class.java, + LocalDateTime::class.java + ).apply { isAccessible = true } + + @Test + fun `buildSurveyChains merges when time overlaps and geometry intersects`() { + val satellite = satellite() + val chains = buildSurveyChains( + satellite, + listOf( + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 100, + slotNumber = 1, + start = baseTime(), + end = baseTime().plusSeconds(30), + contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))" + ), + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 101, + slotNumber = 2, + start = baseTime().plusSeconds(20), + end = baseTime().plusSeconds(60), + contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))" + ) + ) + ) + + assertEquals(1, chains.size) + assertTrue(chains.single().contour.startsWith("POLYGON")) + assertFalse(chains.single().contour.startsWith("MULTIPOLYGON")) + } + + @Test + fun `buildSurveyChains keeps routes separate when time overlaps but geometry does not intersect`() { + val satellite = satellite() + val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger + val logs = captureLogs(logger) { + val chains = buildSurveyChains( + satellite, + listOf( + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 100, + slotNumber = 1, + start = baseTime(), + end = baseTime().plusSeconds(30), + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 101, + slotNumber = 2, + start = baseTime().plusSeconds(20), + end = baseTime().plusSeconds(60), + contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))" + ) + ) + ) + + assertEquals(2, chains.size) + } + + assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") }) + } + + @Test + fun `buildSurveyChains keeps routes separate when contours only touch by boundary`() { + val satellite = satellite() + val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger + val logs = captureLogs(logger) { + val chains = buildSurveyChains( + satellite, + listOf( + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 100, + slotNumber = 1, + start = baseTime(), + end = baseTime().plusSeconds(30), + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 101, + slotNumber = 2, + start = baseTime().plusSeconds(20), + end = baseTime().plusSeconds(60), + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ) + ) + ) + + assertEquals(2, chains.size) + } + + assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") }) + } + + @Test + fun `buildSurveyChains merges adjacent routes when contours are continuous`() { + val satellite = satellite() + val chains = buildSurveyChains( + satellite, + listOf( + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 100, + slotNumber = 1, + start = baseTime(), + end = baseTime().plusSeconds(30), + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + ), + bookedSlot( + satelliteId = satellite.id, + bookedSlotId = 101, + slotNumber = 2, + start = baseTime().plusSeconds(30), + end = baseTime().plusSeconds(60), + contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))" + ) + ) + ) + + assertEquals(1, chains.size) + assertEquals(baseTime(), chains.single().tn) + assertEquals(baseTime().plusSeconds(60), chains.single().tk) + assertEquals(listOf(100L, 101L), chains.single().slotIds) + } + + @Test + fun `buildSurveyChains keeps routes separate when union returns multipolygon`() { + val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger + val logs = captureLogs(logger) { + val method = service.javaClass.getDeclaredMethod( + "unionContoursIfPolygon", + String::class.java, + String::class.java, + Long::class.javaPrimitiveType, + LocalDateTime::class.java, + LocalDateTime::class.java, + Double::class.javaPrimitiveType, + List::class.java, + Class.forName("space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO"), + Long::class.javaPrimitiveType + ) + method.isAccessible = true + + val result = method.invoke( + service, + "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", + 22L, + baseTime(), + baseTime().plusSeconds(30), + 5.0, + listOf(100L), + bookedSlot( + satelliteId = 22, + bookedSlotId = 101, + slotNumber = 2, + start = baseTime().plusSeconds(20), + end = baseTime().plusSeconds(60), + contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))" + ).slot.toDTO(), + 101L + ) + + assertEquals(null, result) + } + + assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") }) + } + + private fun buildSurveyChains( + satellite: TestSatelliteImpl, + bookedSlots: List + ): List { + @Suppress("UNCHECKED_CAST") + return method.invoke( + service, + satellite, + bookedSlots, + baseTime().minusMinutes(5), + baseTime().plusMinutes(10) + ) as List + } + + private fun satellite() = TestSatelliteImpl( + id = 22, + tnCalc = LocalDateTime.of(LocalDate.of(2026, 3, 31), LocalTime.of(0, 0)), + durationCalc = 16 + ) + + private fun bookedSlot( + satelliteId: Long, + bookedSlotId: Long, + slotNumber: Long, + start: LocalDateTime, + end: LocalDateTime, + contour: String + ) = BookedSlotEntity( + bookedSlotId = bookedSlotId, + slot = SlotEntity( + slotId = bookedSlotId, + slotNum = slotNumber, + cycle = 0, + satelliteId = satelliteId, + coveringType = 0, + tn = start, + tk = end, + roll = 5.0, + contour = contour, + revolution = 101, + revolutionSign = RevolutionSign.ASC.toString(), + latitude = 55.75, + longitude = 37.62 + ), + cycle = 0, + status = BookedSlotStatus.BOOKED.name + ) + + private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 31, 10, 0) + + private fun captureLogs(logger: Logger, block: () -> Unit): List { + val appender = ListAppender() + appender.start() + logger.addAppender(appender) + try { + block() + return appender.list.toList() + } finally { + logger.detachAppender(appender) + } + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceIntervalTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceIntervalTest.kt new file mode 100644 index 0000000..7b417d8 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceIntervalTest.kt @@ -0,0 +1,175 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.springframework.test.util.ReflectionTestUtils +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import space.nstart.pcp.slots_service.repository.BookedRequestRepository +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import space.nstart.pcp.slots_service.repository.SlotRepository +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import javax.sql.DataSource +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SlotServiceIntervalTest { + + private fun anyDateTimeValue(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN + private fun eqLongValue(value: Long): Long = eq(value) ?: value + private fun eqDateTimeValue(value: LocalDateTime): LocalDateTime = eq(value) ?: value + + private data class TestSlotCalculationSummaryProjection( + override val satelliteId: Long, + override val slotCount: Long, + override val minTime: LocalDateTime?, + override val maxTime: LocalDateTime?, + override val minRevolution: Long?, + override val maxRevolution: Long?, + ) : SlotRepository.SlotCalculationSummaryProjection + + @Test + fun `allByInterval shifts base slots by closure cycle`() { + val slotRepository = mock(SlotRepository::class.java) + val satellite = TestSatelliteImpl( + id = 22L, + cycleRevs = 243L, + tnCalc = LocalDateTime.of(LocalDate.of(2026, 3, 31), LocalTime.of(0, 0)), + durationCalc = 16 + ) + val service = slotServiceFixture(slotRepository, satellite) + + val intervalStart = LocalDateTime.of(2026, 4, 16, 9, 59) + val intervalStop = LocalDateTime.of(2026, 4, 16, 10, 11) + val baseWindowStart = intervalStart.minusDays(satellite.durationCalc) + val baseWindowStop = intervalStop.minusDays(satellite.durationCalc) + val baseSlot = SlotEntity( + slotId = 100L, + slotNum = 7L, + cycle = 0L, + satelliteId = satellite.id, + coveringType = 0, + tn = LocalDateTime.of(2026, 3, 31, 10, 0), + tk = LocalDateTime.of(2026, 3, 31, 10, 10), + roll = 5.0, + contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))", + revolution = 11L, + revolutionSign = RevolutionSign.ASC.toString(), + latitude = 55.75, + longitude = 37.62 + ) + + `when`( + slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc( + eqLongValue(satellite.id), + eqDateTimeValue(baseWindowStart), + eqDateTimeValue(baseWindowStop) + ) + ).thenReturn(listOf(baseSlot)) + `when`( + slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc( + eqLongValue(satellite.id), + anyDateTimeValue(), + anyDateTimeValue() + ) + ).thenReturn(emptyList()) + `when`( + slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc( + eqLongValue(satellite.id), + eqDateTimeValue(baseWindowStart), + eqDateTimeValue(baseWindowStop) + ) + ).thenReturn(listOf(baseSlot)) + + val actual = service.allByInterval(satellite.id, intervalStart, intervalStop) + + assertEquals(1, actual.size) + assertEquals(1L, actual.first().cycle) + assertEquals(LocalDateTime.of(2026, 4, 16, 10, 0), actual.first().tn) + assertEquals(LocalDateTime.of(2026, 4, 16, 10, 10), actual.first().tk) + assertEquals(11L + satellite.cycleRevs, actual.first().revolution) + } + + @Test + fun `allByInterval rejects reversed interval`() { + val service = slotServiceFixture(mock(SlotRepository::class.java), TestSatelliteImpl(id = 22L)) + + assertFailsWith { + service.allByInterval( + satelliteId = 22L, + timeStart = LocalDateTime.of(2026, 4, 17, 0, 0), + timeStop = LocalDateTime.of(2026, 4, 16, 0, 0) + ) + } + } + + @Test + fun `slotCalculationSummaries returns empty summary for satellites without slots`() { + val slotRepository = mock(SlotRepository::class.java) + val service = slotServiceFixture(slotRepository, TestSatelliteImpl(id = 22L)) + val summary = TestSlotCalculationSummaryProjection( + satelliteId = 22L, + slotCount = 3L, + minTime = LocalDateTime.of(2026, 4, 20, 0, 0), + maxTime = LocalDateTime.of(2026, 4, 21, 0, 0), + minRevolution = 10L, + maxRevolution = 15L + ) + `when`(slotRepository.slotCalculationSummary(22L)).thenReturn(summary) + `when`(slotRepository.slotCalculationSummary(44L)).thenReturn( + TestSlotCalculationSummaryProjection( + satelliteId = 44L, + slotCount = 0L, + minTime = null, + maxTime = null, + minRevolution = null, + maxRevolution = null + ) + ) + + val actual = service.slotCalculationSummaries(listOf(22L, 44L, 22L)) + + assertEquals(2, actual.size) + assertEquals(true, actual[0].calculated) + assertEquals(5L, actual[0].revolutionInterval) + assertEquals(44L, actual[1].satelliteId) + assertEquals(false, actual[1].calculated) + assertEquals(0L, actual[1].slotCount) + } + + private fun slotServiceFixture(slotRepository: SlotRepository, satellite: AbstractSatellite): SlotService { + val service = SlotService() + ReflectionTestUtils.setField(service, "slotRepository", slotRepository) + ReflectionTestUtils.setField(service, "bookedSlotsRepository", mock(BookedSlotsRepository::class.java)) + ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java)) + ReflectionTestUtils.setField(service, "earthService", mock(EarthService::class.java)) + ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java)) + ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(listOf(satellite))) + return service + } + + private fun testSatelliteCatalogClient(satellites: List) = object : SatelliteCatalogClient { + override fun allSatellites(): List = satellites + + override fun satellites(ids: List): List = + ids.distinct().map { satelliteId -> + satellites.find { it.id == satelliteId } + ?: throw CustomValidationException("КА $satelliteId не зарегистрирован") + } + + override fun satellite(id: Long): AbstractSatellite = + satellites.find { it.id == id } + ?: throw CustomValidationException("КА $id не зарегистрирован") + + override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceSunFilterTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceSunFilterTest.kt new file mode 100644 index 0000000..32c2bed --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceSunFilterTest.kt @@ -0,0 +1,303 @@ +package space.nstart.pcp.slots_service.service + +import ballistics.types.EarthType +import ballistics.utils.astro.AstronomerJ2000 +import ballistics.utils.fromDateTime +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.springframework.test.util.ReflectionTestUtils +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow +import space.nstart.pcp.slots_service.repository.BookedRequestRepository +import space.nstart.pcp.slots_service.repository.BookedSlotsRepository +import space.nstart.pcp.slots_service.repository.SlotRepository +import java.time.LocalDateTime +import javax.sql.DataSource +import kotlin.math.PI +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SlotServiceSunFilterTest { + + private fun anyStringValue(): String = anyString() + private fun anyListValue(): List = anyList() ?: emptyList() + private fun anyLongValue(): Long = anyLong() + private fun anyIntValue(): Int = anyInt() + private fun eqValue(value: T): T = eq(value) ?: value + + @Test + fun `polySlotsBatch does not apply sun filter when sun is null`() { + val fixture = slotServiceFixture() + val satellite = fixture.satellite + val templateStart = satellite.tnCalc.plusHours(2) + val slot = templateSlot(satellite, templateStart) + + stubSingleSlotSpatialFetch(fixture, satellite.id, slot) + `when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = fixture.service.polySlotsBatch( + request( + satellite = satellite, + timeStart = templateStart.minusMinutes(1), + timeStop = templateStart.plusMinutes(11), + sun = null + ) + ) + + assertEquals(1, response.size) + assertEquals(1, response.single().slots.size) + } + + @Test + fun `polySlotsBatch drops slot when sun angle is less than or equal to requested threshold`() { + val fixture = slotServiceFixture() + val satellite = fixture.satellite + val templateStart = satellite.tnCalc.plusHours(2) + val slot = templateSlot(satellite, templateStart) + val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude) + + stubSingleSlotSpatialFetch(fixture, satellite.id, slot) + `when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = fixture.service.polySlotsBatch( + request( + satellite = satellite, + timeStart = templateStart.minusMinutes(1), + timeStop = templateStart.plusMinutes(11), + sun = actualAngle + ) + ) + + assertTrue(response.single().slots.isEmpty()) + } + + @Test + fun `polySlotsBatch keeps slot when sun angle is above requested threshold`() { + val fixture = slotServiceFixture() + val satellite = fixture.satellite + val templateStart = satellite.tnCalc.plusHours(2) + val slot = templateSlot(satellite, templateStart) + val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude) + + stubSingleSlotSpatialFetch(fixture, satellite.id, slot) + `when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = fixture.service.polySlotsBatch( + request( + satellite = satellite, + timeStart = templateStart.minusMinutes(1), + timeStop = templateStart.plusMinutes(11), + sun = actualAngle - 0.1 + ) + ) + + assertEquals(1, response.single().slots.size) + } + + @Test + fun `polySlotsBatch uses actual slot time when filtering by sun`() { + val fixture = slotServiceFixture() + val satellite = fixture.satellite + val templateStart = satellite.tnCalc.plusHours(2) + val slot = templateSlot(satellite, templateStart) + val (cycle, templateAngle, actualAngle) = findCycleWithDifferentSunAngle(satellite, slot) + val actualStart = templateStart.plusDays(cycle * satellite.durationCalc) + val actualStop = actualStart.plusMinutes(10) + val requestedSun = (templateAngle + actualAngle) / 2.0 + + stubSingleSlotSpatialFetch(fixture, satellite.id, slot) + `when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = fixture.service.polySlotsBatch( + request( + satellite = satellite, + timeStart = actualStart.minusMinutes(1), + timeStop = actualStop.plusMinutes(1), + sun = requestedSun + ) + ) + + val slots = response.single().slots + if (actualAngle > templateAngle) { + assertEquals(1, slots.size) + assertEquals(actualStart, slots.single().tn) + } else { + assertTrue(slots.isEmpty()) + } + } + + @Test + fun `polySlotsBatch keeps target list when sun filter removes all slots`() { + val fixture = slotServiceFixture() + val satellite = fixture.satellite + val templateStart = satellite.tnCalc.plusHours(2) + val slot = templateSlot(satellite, templateStart) + val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude) + + stubSingleSlotSpatialFetch(fixture, satellite.id, slot) + `when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue())) + .thenReturn(emptyList()) + + val response = fixture.service.polySlotsBatch( + SlotCoverageBatchRequestDTO( + items = listOf( + SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + SlotCoverageTargetDTO(2L, "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))") + ), + timeStart = templateStart.minusMinutes(1), + timeStop = templateStart.plusMinutes(11), + cov = false, + satellites = listOf(satellite.id), + sun = actualAngle + ) + ) + + assertEquals(2, response.size) + assertTrue(response.all { it.slots.isEmpty() }) + } + + private fun slotServiceFixture(): SlotServiceFixture { + val slotRepository = mock(SlotRepository::class.java) + val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) + val satellite = TestSatelliteImpl(id = 22L) + + val service = SlotService() + ReflectionTestUtils.setField(service, "slotRepository", slotRepository) + ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository) + ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java)) + ReflectionTestUtils.setField(service, "earthService", mock(EarthService::class.java)) + ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java)) + ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(listOf(satellite))) + return SlotServiceFixture(service, slotRepository, bookedSlotsRepository, satellite) + } + + private fun testSatelliteCatalogClient(satellites: List) = object : SatelliteCatalogClient { + override fun allSatellites(): List = satellites + + override fun satellites(ids: List): List = + ids.distinct().map { satelliteId -> + satellites.find { it.id == satelliteId } + ?: throw CustomValidationException("КА $satelliteId не зарегистрирован") + } + + override fun satellite(id: Long): AbstractSatellite = + satellites.find { it.id == id } + ?: throw CustomValidationException("КА $id не зарегистрирован") + + override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request + } + + private fun request( + satellite: AbstractSatellite, + timeStart: LocalDateTime, + timeStop: LocalDateTime, + sun: Double? + ) = SlotCoverageBatchRequestDTO( + items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")), + timeStart = timeStart, + timeStop = timeStop, + cov = false, + satellites = listOf(satellite.id), + sun = sun + ) + + private fun templateSlot( + satellite: AbstractSatellite, + templateStart: LocalDateTime + ) = SlotEntity( + slotId = 101L, + slotNum = 1L, + cycle = 0L, + satelliteId = satellite.id, + coveringType = 0, + tn = templateStart, + tk = templateStart.plusMinutes(10), + roll = 10.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 500L, + revolutionSign = "ASC", + latitude = 55.75, + longitude = 37.62 + ) + + private fun stubSingleSlotSpatialFetch( + fixture: SlotServiceFixture, + satelliteId: Long, + slot: SlotEntity + ) { + `when`( + fixture.slotRepository.findTargetSlotRows( + anyListValue(), + anyIntValue() + ) + ).thenReturn( + listOf( + BatchCoverageSlotRow( + targetId = 1L, + slotId = slot.slotId ?: -1L, + slotNum = slot.slotNum, + cycle = slot.cycle, + satelliteId = slot.satelliteId, + coveringType = slot.coveringType, + tn = slot.tn, + tk = slot.tk, + roll = slot.roll, + contour = slot.contour, + revolution = slot.revolution, + revolutionSign = slot.revolutionSign, + latitude = slot.latitude, + longitude = slot.longitude + ) + ) + ) + } + + private fun findCycleWithDifferentSunAngle( + satellite: AbstractSatellite, + slot: SlotEntity + ): Triple { + val templateAngle = sunAngleDegrees(slot.tn, slot.latitude, slot.longitude) + for (cycle in 1L..40L) { + val actualTime = slot.tn.plusDays(cycle * satellite.durationCalc) + val actualAngle = sunAngleDegrees(actualTime, slot.latitude, slot.longitude) + if (abs(actualAngle - templateAngle) > 0.5) { + return Triple(cycle, templateAngle, actualAngle) + } + } + error("Unable to find cycle with distinct sun angle for actual slot time test") + } + + private fun sunAngleDegrees(time: LocalDateTime, latitudeDegrees: Double, longitudeDegrees: Double): Double { + val astronomer = AstronomerJ2000(EarthType.PZ90d02) + val position = astronomer.earth.blh2xyz( + latitudeDegrees / 180.0 * PI, + longitudeDegrees / 180.0 * PI, + 0.0 + ) + return astronomer.sunAngle(fromDateTime(time), position) * 180.0 / PI + } + + private data class SlotServiceFixture( + val service: SlotService, + val slotRepository: SlotRepository, + val bookedSlotsRepository: BookedSlotsRepository, + val satellite: AbstractSatellite + ) +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceUpdateSlotsProfileTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceUpdateSlotsProfileTest.kt new file mode 100644 index 0000000..1cf3157 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceUpdateSlotsProfileTest.kt @@ -0,0 +1,95 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.springframework.test.util.ReflectionTestUtils +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl +import java.lang.reflect.InvocationTargetException +import java.time.LocalDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SlotServiceUpdateSlotsProfileTest { + + @Test + fun `updateSlotsProfile sends current satellite slot profile to catalog`() { + val service = SlotService() + val catalogClient = RecordingSatelliteCatalogClient() + ReflectionTestUtils.setField(service, "satelliteCatalogClient", catalogClient) + val satellite = TestSatelliteImpl( + id = 22L, + cycleRevs = 275L, + tnCalc = LocalDateTime.of(2026, 4, 20, 11, 35), + durationCalc = 18L, + slotDuration = 12L, + maxChainLength = 7, + angles = listOf(18.5, 21.5, 23.0, 26.0) + ) + + invokeUpdateSlotsProfile(service, satellite) + + assertEquals(22L, catalogClient.updatedSatelliteId) + assertEquals( + SatelliteSlotProfileDTO( + cycleRevs = 275L, + tnCalc = LocalDateTime.of(2026, 4, 20, 11, 35), + slotDuration = 12L, + durationCalcDays = 18L, + maxChainLength = 7, + defaultAngles = listOf( + SatelliteSlotAngleDTO(angleBegin = 18.5, angleEnd = 21.5), + SatelliteSlotAngleDTO(angleBegin = 23.0, angleEnd = 26.0) + ) + ), + catalogClient.updatedProfile + ) + } + + @Test + fun `updateSlotsProfile rejects odd number of slot angle boundaries`() { + val service = SlotService() + ReflectionTestUtils.setField(service, "satelliteCatalogClient", RecordingSatelliteCatalogClient()) + val satellite = TestSatelliteImpl( + id = 22L, + angles = listOf(18.5, 21.5, 23.0) + ) + + assertFailsWith { + invokeUpdateSlotsProfile(service, satellite) + } + } + + private fun invokeUpdateSlotsProfile(service: SlotService, satellite: AbstractSatellite) { + val method = SlotService::class.java.getDeclaredMethod( + "updateSlotsProfile", + AbstractSatellite::class.java + ) + method.isAccessible = true + try { + method.invoke(service, satellite) + } catch (ex: InvocationTargetException) { + throw ex.targetException + } + } + + private class RecordingSatelliteCatalogClient : SatelliteCatalogClient { + var updatedSatelliteId: Long? = null + var updatedProfile: SatelliteSlotProfileDTO? = null + + override fun allSatellites(): List = emptyList() + + override fun satellites(ids: List): List = emptyList() + + override fun satellite(id: Long): AbstractSatellite = + throw UnsupportedOperationException("Not used in this test") + + override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO { + updatedSatelliteId = id + updatedProfile = request + return request + } + } +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/util/LongitudeWrapGeometryTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/util/LongitudeWrapGeometryTest.kt new file mode 100644 index 0000000..fec7c98 --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/util/LongitudeWrapGeometryTest.kt @@ -0,0 +1,80 @@ +package space.nstart.pcp.slots_service.util + +import org.junit.jupiter.api.Test +import org.locationtech.jts.geom.MultiPolygon +import org.locationtech.jts.geom.Polygon +import org.locationtech.jts.io.WKTReader +import kotlin.test.assertEquals +import kotlin.test.assertContentEquals +import kotlin.test.assertTrue + +class LongitudeWrapGeometryTest { + + private val reader = WKTReader() + + @Test + fun `normalizes prime meridian polygon into continuous longitude range`() { + val geometry = LongitudeWrapGeometry.normalizeToContinuous360( + reader.read("POLYGON ((-1 1, 1 1, 1 -1, -1 -1, -1 1))") + ) as Polygon + + val xs = geometry.coordinates.map { it.x } + + assertEquals(359.0, xs.min()) + assertEquals(361.0, xs.max()) + } + + @Test + fun `equivalent polygons around zero meridian intersect after normalization`() { + val slotGeometry = LongitudeWrapGeometry.normalizeToContinuous360( + reader.read("POLYGON ((359 1, 1 1, 1 -1, 359 -1, 359 1))") + ) + val queryGeometry = LongitudeWrapGeometry.normalizeToContinuous360( + reader.read("POLYGON ((-0.5 0.5, 0.5 0.5, 0.5 -0.5, -0.5 -0.5, -0.5 0.5))") + ) + + assertTrue(slotGeometry.intersects(queryGeometry)) + } + + @Test + fun `builds search variants for polygons crossing zero meridian`() { + val variants = LongitudeWrapGeometry.buildSearchVariantsWkt( + "POLYGON ((355 1, 361 1, 361 -1, 355 -1, 355 1))" + ).map { reader.read(it) as Polygon } + + val bounds = variants.map { listOf(it.envelopeInternal.minX, it.envelopeInternal.maxX) } + + assertContentEquals( + listOf( + listOf(-5.0, 1.0), + listOf(355.0, 361.0), + listOf(715.0, 721.0) + ), + bounds + ) + } + + @Test + fun `aligns eastern slot copy to western continuous target band`() { + val target = reader.read("POLYGON ((355 1, 361 1, 361 -1, 355 -1, 355 1))") + val easternSlot = reader.read("POLYGON ((0.2 0.5, 0.8 0.5, 0.8 -0.5, 0.2 -0.5, 0.2 0.5))") + + val aligned = LongitudeWrapGeometry.alignToReference(easternSlot, target) as Polygon + + assertEquals(360.2, aligned.envelopeInternal.minX) + assertEquals(360.8, aligned.envelopeInternal.maxX) + assertTrue(aligned.intersects(LongitudeWrapGeometry.normalizeToContinuous360(target))) + } + + @Test + fun `repairs wrap induced self intersection into valid multipolygon`() { + val geometry = LongitudeWrapGeometry.normalizeToContinuous360( + reader.read("POLYGON ((179 2, -179 2, 179 0, -179 0, 179 -2, -179 -2, 179 2))") + ) + + assertTrue(geometry is MultiPolygon) + assertTrue(geometry.isValid) + assertEquals(179.0, geometry.envelopeInternal.minX) + assertEquals(181.0, geometry.envelopeInternal.maxX) + } +} diff --git a/services/slots-service/src/test/resources/application.yaml b/services/slots-service/src/test/resources/application.yaml new file mode 100644 index 0000000..9e63c51 --- /dev/null +++ b/services/slots-service/src/test/resources/application.yaml @@ -0,0 +1,65 @@ +spring: + application: + name: slots-service + cloud: + config: + enabled: false + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://192.168.100.160:35400/pcp_slots + username: postgres + password: password + hikari: + maximum-pool-size: 2 + minimum-idle: 0 + connection-timeout: 5000 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + codec: + max-in-memory-size: 20MB + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + .: ERROR + file: + name: ./logs/application.log + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +settings: + earth-grid-service: http://192.168.60.68:7005 + +server: + port: 7006 diff --git a/services/spring-cloud-config-server/Dockerfile b/services/spring-cloud-config-server/Dockerfile new file mode 100644 index 0000000..b32686f --- /dev/null +++ b/services/spring-cloud-config-server/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="-jar" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8888 +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/services/spring-cloud-config-server/README.md b/services/spring-cloud-config-server/README.md new file mode 100644 index 0000000..9cf44f9 --- /dev/null +++ b/services/spring-cloud-config-server/README.md @@ -0,0 +1,29 @@ +# spring-cloud-config-server + +Spring Cloud Config Server для PCP. + +Сервер читает конфигурацию из GitLab-репозитория через backend `spring.cloud.config.server.git`. + +Обязательные переменные окружения: + +- `CONFIG_GIT_URI`: HTTPS URL GitLab-репозитория с конфигами, например `https://gitlab.example.com/group/pcp-config.git` +- `CONFIG_GIT_TOKEN`: access token для чтения приватного репозитория + +Рекомендуемые переменные окружения: + +- `CONFIG_GIT_USERNAME`: логин для GitLab HTTPS auth. Для token-based auth обычно подходит `oauth2` +- `CONFIG_GIT_DEFAULT_LABEL`: ветка с конфигами, по умолчанию `main` +- `CONFIG_GIT_SEARCH_PATHS`: подкаталог внутри репозитория, по умолчанию `config-repo` +- `SERVER_PORT`: порт сервера, по умолчанию `8888` + +Для локального запуска в корне репозитория подготовлен `.env` и шаблон `.env.example`. + +Запуск в Docker Compose из корня проекта: + +`docker compose up --build spring-cloud-config-server` + +Пример запроса: + +`GET /pcp-stations-service/default` + +Если конфиги лежат в корне репозитория, задайте `CONFIG_GIT_SEARCH_PATHS=`. diff --git a/services/spring-cloud-config-server/build.gradle.kts b/services/spring-cloud-config-server/build.gradle.kts new file mode 100644 index 0000000..74b279f --- /dev/null +++ b/services/spring-cloud-config-server/build.gradle.kts @@ -0,0 +1,73 @@ +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +} + +version = "1.0.0" + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.cloud:spring-cloud-config-server") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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/spring-cloud-config-server/src/main/kotlin/space/nstart/pcp/spring_cloud_config_server/SpringCloudConfigServerApplication.kt b/services/spring-cloud-config-server/src/main/kotlin/space/nstart/pcp/spring_cloud_config_server/SpringCloudConfigServerApplication.kt new file mode 100644 index 0000000..a304048 --- /dev/null +++ b/services/spring-cloud-config-server/src/main/kotlin/space/nstart/pcp/spring_cloud_config_server/SpringCloudConfigServerApplication.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.spring_cloud_config_server + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.cloud.config.server.EnableConfigServer + +@EnableConfigServer +@SpringBootApplication +class SpringCloudConfigServerApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/spring-cloud-config-server/src/main/resources/application.yaml b/services/spring-cloud-config-server/src/main/resources/application.yaml new file mode 100644 index 0000000..7c78e5f --- /dev/null +++ b/services/spring-cloud-config-server/src/main/resources/application.yaml @@ -0,0 +1,65 @@ +spring: + application: + name: spring-cloud-config-server + profiles: + group: + local: native + cloud: + config: + server: + git: + uri: ${CONFIG_GIT_URI:} + default-label: ${CONFIG_GIT_DEFAULT_LABEL:main} + search-paths: ${CONFIG_GIT_SEARCH_PATHS:config-repo} + clone-on-start: ${CONFIG_GIT_CLONE_ON_START:false} + force-pull: ${CONFIG_GIT_FORCE_PULL:true} + timeout: ${CONFIG_GIT_TIMEOUT:10} + username: ${CONFIG_GIT_USERNAME:oauth2} + password: ${CONFIG_GIT_TOKEN:} + skipSslValidation: ${CONFIG_GIT_SKIP_SSL_VALIDATION:false} + boot: + admin: + client: + enabled: false + +server: + port: ${SERVER_PORT:8888} + +management: + health: + client-config-server: + enabled: false + endpoints: + web: + exposure: + include: health,info,env,refresh + endpoint: + health: + show-details: always + +logging: + level: + org.springframework.cloud.config.server: INFO + +--- +spring: + config: + activate: + on-profile: local + cloud: + config: + server: + native: + search-locations: ${CONFIG_NATIVE_SEARCH_LOCATIONS:optional:file:./config-repo,optional:file:../config-repo,optional:file:../../config-repo} + +--- +spring: + config: + activate: + on-profile: test + cloud: + config: + server: + git: + uri: https://gitlab.example.invalid/group/config-repo.git + clone-on-start: false diff --git a/services/spring-cloud-config-server/src/test/kotlin/space/nstart/pcp/spring_cloud_config_server/SpringCloudConfigServerApplicationTests.kt b/services/spring-cloud-config-server/src/test/kotlin/space/nstart/pcp/spring_cloud_config_server/SpringCloudConfigServerApplicationTests.kt new file mode 100644 index 0000000..f0d9f58 --- /dev/null +++ b/services/spring-cloud-config-server/src/test/kotlin/space/nstart/pcp/spring_cloud_config_server/SpringCloudConfigServerApplicationTests.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.spring_cloud_config_server + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class SpringCloudConfigServerApplicationTests { + + @Test + fun contextLoads() { + } +} diff --git a/services/tle-monitoring-service/Dockerfile b/services/tle-monitoring-service/Dockerfile new file mode 100644 index 0000000..e1af0fd --- /dev/null +++ b/services/tle-monitoring-service/Dockerfile @@ -0,0 +1,8 @@ +FROM bellsoft/liberica-openjre-alpine:21.0.5 + +ENV JAVA_OPTS="" + +ADD ./build/libs/*.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"] diff --git a/services/tle-monitoring-service/build.gradle.kts b/services/tle-monitoring-service/build.gradle.kts new file mode 100644 index 0000000..cc1cbb2 --- /dev/null +++ b/services/tle-monitoring-service/build.gradle.kts @@ -0,0 +1,103 @@ + +group = "space.nstart.pcp" + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + kotlin("plugin.lombok") + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.sonarqube") + jacoco +// id("org.graalvm.buildtools.native") +} + +version = "1.0.0" + + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +dependencies { + implementation(project(":libs:pcp-types-lib")) + implementation(project(":libs:ballistics-lib")) + implementation("${property("dep.spring.actuator")}") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("jakarta.validation:jakarta.validation-api") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-starter-webflux") +// implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}") + + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.cloud:spring-cloud-starter-config") + + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + implementation("jakarta.validation:jakarta.validation-api") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + testImplementation("junit:junit") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}") + } +} + +tasks.withType { + manifest { + attributes["Built-By"] = "nstart" + attributes["Implementation-Version"] = archiveVersion + } +} + +tasks.withType { + enabled = true + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) + systemProperty("spring.profiles.active", "test") +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + 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/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/TleMonitoringServiceApplication.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/TleMonitoringServiceApplication.kt new file mode 100644 index 0000000..99b5cd7 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/TleMonitoringServiceApplication.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.tle_monitoring_service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling +class TleMonitoringServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/GlobalExceptionHandler.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/GlobalExceptionHandler.kt new file mode 100644 index 0000000..3fca50e --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/GlobalExceptionHandler.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.tle_monitoring_service.configuration + +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.support.WebExchangeBindException +import reactor.core.publisher.Mono +import kotlin.collections.forEach +import kotlin.collections.set +import kotlin.to + +class CustomValidationException(message: String) : RuntimeException(message) +class CustomErrorException(message: String) : RuntimeException(message) + + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(CustomValidationException::class) + fun handleValidation(ex: CustomValidationException): ResponseEntity> { + return ResponseEntity.badRequest() + .body(mapOf("error" to ex.message!!)) + } + @ExceptionHandler(CustomErrorException::class) + fun handleError(ex: CustomErrorException): ResponseEntity> { + return ResponseEntity.internalServerError() + .body(mapOf("error" to ex.message!!)) + } +} \ No newline at end of file diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/KafkaConfig.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/KafkaConfig.kt new file mode 100644 index 0000000..d7a2614 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/KafkaConfig.kt @@ -0,0 +1,135 @@ +package space.nstart.pcp.tle_monitoring_service.configuration + +import org.apache.kafka.clients.admin.AdminClientConfig +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.header.Header +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaAdmin +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.listener.adapter.RecordFilterStrategy +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import java.nio.charset.StandardCharsets +import kotlin.String + + +@ConditionalOnProperty(name=["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Configuration +class KafkaConfig { + companion object { + const val HEADER_NAME: String = "type" + const val TARGET_CONSUMER_HEADER_NAME: String = "target_consumer" + } + + @Value("\${spring.kafka.template.default-topic}") + private lateinit var defaultTopic: String + + @Bean + fun tleTopic(): NewTopic = + NewTopic(defaultTopic, 1, 1) + + + @Value("\${spring.kafka.bootstrap-servers}") + private lateinit var addr: String + + @Value("\${spring.kafka.consumer.group-id}") + private lateinit var groupId: String + + @Bean + fun template(): KafkaTemplate { + return KafkaTemplate(producerFactory()) + } + + @Bean + fun kafkaAdmin(): KafkaAdmin { + + val configs = hashMapOf() + configs[AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG] = addr + + return KafkaAdmin(configs) + } + + @Bean + fun consumerFactory(): ConsumerFactory { + + val props = hashMapOf() + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr + props[ConsumerConfig.GROUP_ID_CONFIG] = groupId + props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "latest" + props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + + return DefaultKafkaConsumerFactory(props) + } + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory() + .apply { setConsumerFactory(consumerFactory()) } + + @Bean + fun producerFactory(): ProducerFactory { + + val configProps = hashMapOf() + configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr + configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + + return DefaultKafkaProducerFactory(configProps) + } + + private fun getRecordFilterStrategy(event: PcpKafkaEvent): RecordFilterStrategy { + return RecordFilterStrategy { consumerRecord: ConsumerRecord -> + val eventTypeHeader: Header? = consumerRecord.headers().lastHeader(HEADER_NAME) + if (eventTypeHeader != null) { + val headerValue = String(eventTypeHeader.value(), StandardCharsets.UTF_8) + return@RecordFilterStrategy event.name != headerValue + } + true + } + } + + private fun getRecordFilterStrategyByConsumer(): RecordFilterStrategy { + return RecordFilterStrategy { consumerRecord: ConsumerRecord -> + val consumerGroupHeader: Header? = consumerRecord.headers().lastHeader(TARGET_CONSUMER_HEADER_NAME) + if (consumerGroupHeader != null) { + val headerValue = String(consumerGroupHeader.value(), StandardCharsets.UTF_8) + return@RecordFilterStrategy "respondedListener" != headerValue + } + true + } + } + + @Bean("placedIcFilter") + fun placedFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.ICPlacedEvent) + } + + @Bean("updatedIcFilter") + fun updatedIcFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.ICUpdatedEvent) + } + + @Bean("satelliteDeletedFilter") + fun satelliteDeletedFilter(): RecordFilterStrategy { + return getRecordFilterStrategy(PcpKafkaEvent.SatelliteDeletedEvent) + } + + @Bean("respondedFilter") + fun respondedFilter(): RecordFilterStrategy { + return getRecordFilterStrategyByConsumer() + } +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/TlePollingProperties.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/TlePollingProperties.kt new file mode 100644 index 0000000..e71c437 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/TlePollingProperties.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp.tle_monitoring_service.configuration + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "tle.polling") +data class TlePollingProperties( + var enabled: Boolean = false, + var timeoutSeconds: Long = 10_800, +) diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/WebClientConfig.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/WebClientConfig.kt new file mode 100644 index 0000000..e30d411 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/configuration/WebClientConfig.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.tle_monitoring_service.configuration + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration + +@Configuration +class WebClientConfig { + + @Bean + fun webClientBuilder(): WebClient.Builder { + val httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(10)) + + return WebClient.builder() + .clientConnector(ReactorClientHttpConnector(httpClient)) + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) + } + } + + @Bean + fun webClient(webClientBuilder: WebClient.Builder): WebClient = webClientBuilder.build() +} 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 new file mode 100644 index 0000000..a19288d --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TLEController.kt @@ -0,0 +1,54 @@ +package space.nstart.pcp.tle_monitoring_service.controller + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController +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 + + +@RestController +@RequestMapping("v1/api/tle") +class TLEController { + + + @Autowired + private lateinit var tleRepository: TLERepository + @Autowired + 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]) + ) + } + +// @GetMapping("/test") +// fun update() = celestrakService.updateDB() + + + @PostMapping("load-array") + fun arra(@RequestBody data : String, @RequestParam name : String) = + celestrakService.loadArray(data, name) + + + @PostMapping ("check-cycle") + fun checkCycle(@RequestParam id : Long) = + celestrakService.checkCycle(id) + + @PostMapping("from-celestrack") + 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/controller/TlePollingSettingsController.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TlePollingSettingsController.kt new file mode 100644 index 0000000..7b94495 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TlePollingSettingsController.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.tle_monitoring_service.controller + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +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.tle_monitoring_service.service.TlePollingSettingsService +import space.nstart.pcp.tle_monitoring_service.service.UpdateTlePollingSettingsRequest + +@RestController +@RequestMapping("v1/api/tle/polling-settings") +class TlePollingSettingsController( + private val pollingSettingsService: TlePollingSettingsService, +) { + + @GetMapping + fun current() = pollingSettingsService.current() + + @PutMapping + fun update(@RequestBody request: UpdateTlePollingSettingsRequest) = + pollingSettingsService.update(request) +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt new file mode 100644 index 0000000..665db80 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.tle_monitoring_service.entity + +import jakarta.persistence.* +import java.time.LocalDateTime +import java.time.ZoneOffset + + +@Entity +@Table(name = "tle") +class TLEEntity ( + @Id + @Column(name = "tle_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + + @Column(nullable = false) + val satelliteId : Long = 0, + + @Column(nullable = false) + val revolution : Long = 0, + + @Column(nullable = false) + val tle : String = "", + var timeTle : LocalDateTime? = null, + @Column(name = "time_rec", nullable = false) + val timeRec : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) +) diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/KafkaProducer.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/KafkaProducer.kt new file mode 100644 index 0000000..264a38c --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/KafkaProducer.kt @@ -0,0 +1,75 @@ +package space.nstart.pcp.tle_monitoring_service.message + +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.SendResult +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.tle_monitoring_service.configuration.KafkaConfig +import tools.jackson.core.JacksonException +import tools.jackson.databind.ObjectMapper +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import java.util.function.BiConsumer +import kotlin.Any +import kotlin.String + + +@ConditionalOnProperty(name=["spring.kafka.bootstrap-servers"], matchIfMissing = false) +@Component +class KafkaProducer { + + @Value("\${spring.kafka.template.default-topic}") + private val defaultTopic: String? = null + + @Value("\${spring.application.name:opkat-ks-request-service}") + private val applicationName: String = "" + + @Autowired + private lateinit var kafkaTemplate: KafkaTemplate + + @Autowired + private lateinit var objectMapper: ObjectMapper + + private val log: Logger = LoggerFactory.getLogger(this::class.java) + + fun send(message: KafkaMessage, topic: String? = null, extraHeaders: Map? = null) { + try { + message.source = applicationName + + val jsonMessage = objectMapper.writeValueAsString(message) + val selectedTopic = topic ?: defaultTopic + val record: ProducerRecord = ProducerRecord( + selectedTopic, + message.traceId, + jsonMessage + ) + record.headers().add(KafkaConfig.HEADER_NAME, message.type.enumCast()?.name?.toByteArray(StandardCharsets.UTF_8)) + extraHeaders?.forEach { record.headers().add(it.key, it.value.toString().toByteArray(StandardCharsets.UTF_8)) } + val future: CompletableFuture> = kafkaTemplate.send(record) + future.whenCompleteAsync { result: SendResult, ex: Throwable? -> + if (ex == null) log.debug( + "В Kafka отправлено сообщение. Topic: {}. Header: {}. Key: {}. Value: {}.", + result.producerRecord.topic(), + result.producerRecord.headers().lastHeader(KafkaConfig.HEADER_NAME), + result.producerRecord.key(), + result.producerRecord.value() + ) + else log.error( + "Ошибка отправки сообщения в Kafka. Topic: {}. Header: {}. Key: {}. Value: {}.", + selectedTopic, + message.type.enumCast()?.name, + message.traceId, + jsonMessage + ) + } + } catch (e: JacksonException) { + throw RuntimeException("Ошибка конвертации сообщения в json: " + e.message, e) + } + } +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/SatelliteDeletedKafkaListener.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/SatelliteDeletedKafkaListener.kt new file mode 100644 index 0000000..87c7a0a --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/SatelliteDeletedKafkaListener.kt @@ -0,0 +1,72 @@ +package space.nstart.pcp.tle_monitoring_service.message + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.tle_monitoring_service.service.SatelliteDeletedService + +private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-tle-monitoring-service-satellite-deleted" + +@Component +class SatelliteDeletedKafkaListener( + objectMapperProvider: ObjectProvider, + private val satelliteDeletedService: SatelliteDeletedService +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules() + + @KafkaListener( + id = "satelliteDeletedFilter", + topics = ["\${app.kafka.topics.satellites:pcp.satellites}"], + groupId = SATELLITE_DELETED_CONSUMER_GROUP, + filter = "satelliteDeletedFilter" + ) + fun consume(message: String?) { + if (message.isNullOrBlank()) { + logger.warn("Received empty SatelliteDeletedEvent Kafka message") + return + } + + try { + val kafkaMessage = objectMapper.readValue( + message, + object : TypeReference>() {} + ) + val event = kafkaMessage.data + logger.info( + "Received SatelliteDeletedEvent in tle-monitoring-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + event.deleteIdentifiers().forEach { satelliteId -> + satelliteDeletedService.deleteSatelliteData(satelliteId) + } + logger.info( + "SatelliteDeletedEvent processed in tle-monitoring-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}", + event.satelliteId, + event.noradId, + kafkaMessage.id, + kafkaMessage.traceId, + kafkaMessage.source, + SATELLITE_DELETED_CONSUMER_GROUP + ) + } catch (exception: Exception) { + logger.error("Failed to process SatelliteDeletedEvent in tle-monitoring-service", exception) + throw exception + } + } + + private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List = + listOfNotNull(satelliteId, noradId).distinct() +} 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 new file mode 100644 index 0000000..ffceb66 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/repository/TLERepository.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.tle_monitoring_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp.tle_monitoring_service.entity.TLEEntity + +interface TLERepository : JpaRepository { + + fun findBySatelliteIdAndRevolution(id : Long, rev : Long) : List + + fun findAllBySatelliteId(id : Long) : List + + fun countBySatelliteId(id: Long): Long + + fun deleteAllBySatelliteId(id: Long): Long +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt new file mode 100644 index 0000000..67997d5 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt @@ -0,0 +1,330 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import ballistics.Ballistics +import ballistics.types.BallisticsError +import ballistics.types.TLE +import ballistics.utils.toDateTime +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import org.springframework.web.reactive.function.client.bodyToMono +import org.springframework.web.util.UriComponentsBuilder +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO +import space.nstart.pcp.pcp_types_lib.message.KafkaMessage +import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent +import space.nstart.pcp.tle_monitoring_service.configuration.CustomErrorException +import space.nstart.pcp.tle_monitoring_service.entity.TLEEntity +import space.nstart.pcp.tle_monitoring_service.message.KafkaProducer +import space.nstart.pcp.tle_monitoring_service.repository.TLERepository +import java.time.Duration +import java.time.LocalDateTime +import java.util.UUID +import kotlin.math.PI +import kotlin.math.abs +import kotlin.text.split + + +@Service +class CelesTrakService( + private val webClient: WebClient, + private val pollingSettingsService: TlePollingSettingsService, +) { + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = jacksonObjectMapper() + + @Autowired + private lateinit var sender: KafkaProducer + + @Autowired + private lateinit var tleRepository: TLERepository + + @Autowired + private lateinit var complexMissionService: ComplexMissionService + + companion object { + private const val BASE_URL = "https://celestrak.org" + private const val USER_AGENT = "SatelliteTracker/1.0 (Spring Boot)" + } + + + @Scheduled(fixedDelay = 1000) + fun findExpired(): Flux { + if (!pollingSettingsService.shouldStartPolling()) { + return Flux.empty() + } + + val settings = pollingSettingsService.current() + pollingSettingsService.markPollingStarted() + logger.info("Запуск цикла опроса TLE. timeoutSeconds=${settings.timeoutSeconds}") + + return complexMissionService.getSatellites().filter { s -> s.scanTLE }.flatMap { + sat -> + getTleByNoradIdRaw(sat.noradId, send = true, sendForce = false) + } + } + + + + + fun updateDB(){ + val bal = Ballistics() + tleRepository.findAll().forEach { tle -> + try{ + val dat = tle.tle.split("\r\n") + val params = bal.getTLEParams(TLE(dat[1], dat[2])) + tleRepository.save(tle.apply { + timeTle = params.time + }) + + } catch (ex : Exception){ + logger.warn("Ошибка разбора TLE ${ex.message}") + } + } + } + + + + fun loadArray(data : String, name : String){ + + val tles = data.split("\n") + var first = true + var s1 : String = "" + var s2 : String + for (s in tles){ + if (first){ + s1 = s + } else { + s2 = s + val id = getNoradIdFromTLE(s1) + mkTle(TLEDTO(name,s1, s2), id, false, false) + } + first = ! first + } + + } + + fun getTleArrayByNoradIdRaw(noradId: Long, name: String): Mono { + // Используем UriComponentsBuilder для корректного построения URL + val uri = UriComponentsBuilder.fromUriString("https://www.space-track.org/basicspacedata/query/class/gp_history/norad_cat_id/$noradId/CREATION_DATE/%3Enow-60/format/tle") + .build() + .toUri() + logger.info("Запрос архива TLE для NORAD ID: $noradId, URI: $uri") + return webClient.get() + .uri(uri) + .retrieve() + .bodyToMono() + .timeout(Duration.ofSeconds(15)) + .doOnNext { rawText -> + loadArray(rawText, name) + } + .doOnError { error -> + handleError(error, noradId, "raw-array") + } + .then() + } + + + + /** + * Получение TLE в сыром текстовом формате + */ + fun getTleByNoradIdRaw(noradId: Long, send : Boolean, sendForce : Boolean): Mono { + // Используем UriComponentsBuilder для корректного построения URL + val uri = UriComponentsBuilder.fromUriString("$BASE_URL/NORAD/elements/gp.php") + .queryParam("CATNR", noradId) + .queryParam("FORMAT", "tle") + .build() + .toUri() + logger.info("Запрос raw TLE для NORAD ID: $noradId, URI: $uri") + return webClient.get() + .uri(uri) + .header("User-Agent", USER_AGENT) + .retrieve() + .bodyToMono() + .timeout(Duration.ofSeconds(15)) + .map { rawText -> + parseRawTle(rawText, noradId) + } + .onErrorResume { error -> + handleError(error, noradId, "raw") + Mono.just(TLEDTO()) + } + .doOnSuccess { tle -> + if (tle != null) { + mkTle(tle, noradId, send, sendForce) + } + } + } + + + fun mkTle(tle : TLEDTO, noradId : Long, send : Boolean, sendForce : Boolean){ + val rev = getRevolutionNumberFromTLE(tle.second) + if (rev <= 0) + logger.error("Не удалось прочитать номер витка из строки ${tle.second} для id $noradId") + else { + + + if (tleRepository.findBySatelliteIdAndRevolution(noradId, rev).isEmpty()) { + + val bal = Ballistics() + + try { + + val params = bal.getTLEParams(TLE(tle.first, tle.second)) + tleRepository.save( + TLEEntity( + null, + noradId, + rev, + "${tle.header ?: "---"}\r\n${tle.first}\r\n${tle.second}", + timeTle = params.time + + ) + ) + logger.info("Данные TLE для noradId $noradId сохранены в БД") + } catch (ex: Exception) { + logger.warn("Ошибка разбора TLE ${ex.message}") + throw CustomErrorException("Ошибка разбора TLE ${ex.message}") + } + + + + if (send && !sendForce) { + sender.send( + KafkaMessage( + type = PcpKafkaEvent.ICUpdatedEvent, + data = tle, + traceId = UUID.randomUUID().toString() + ) + ) + logger.info("Данные TLE для noradId $noradId отправлены в очередь сообщений") + } + } else + logger.info("Данные TLE для noradId $noradId не изменились") + + + if (sendForce) { + logger.info("Данные TLE для noradId $noradId принудительно отправлены в очередь сообщений") + sender.send( + KafkaMessage( + type = PcpKafkaEvent.ICUpdatedEvent, + data = tle, + traceId = UUID.randomUUID().toString() + ) + ) + } + } + } + + fun getRevolutionNumberFromTLE(line2: String): Long { + // Номер витка находится в позициях 64-68 (индексы 63-67) + if (line2.length >= 68) { + val revNumberStr = line2.substring(63, 68).trim() + return revNumberStr.toLongOrNull() ?: 0 + } + return 0 + } + + fun getNoradIdFromTLE(line1: String): Long { + // Номер витка находится в позициях 64-68 (индексы 63-67) + if (line1.length >= 15) { + val noradId = line1.substring(2, 7).trim() + return noradId.toLongOrNull() ?: 0 + } + return 0 + } + + /** + * Парсинг сырого TLE текста + */ + private fun parseRawTle(rawText: String, noradId: Long): TLEDTO { + val lines = rawText.lines() + .map { it.trim() } + .filter { it.isNotBlank() } + return when { + lines.size >= 3 -> { + TLEDTO( + lines[0], + lines[1], + lines[2] + ) + } + lines.size == 2 -> { + // Если нет имени объекта + TLEDTO( + "NORAD-$noradId", + lines[0], + lines[1] + ) + } + else -> { + logger.warn("Некорректный TLE формат для NORAD ID: $noradId") + throw CustomErrorException("Не удалось получить TLE: $lines") + } + } + } + + private fun handleError(error: Throwable, noradId: Long, format: String) { + when (error) { + is WebClientResponseException -> { + logger.error("HTTP ошибка при запросе $format TLE для NORAD ID: $noradId. " + + "Статус: ${error.statusCode}, Тело: ${error.responseBodyAsString}", error) + throw CustomErrorException("HTTP ошибка при запросе $format TLE для NORAD ID: $noradId. " + + "Статус: ${error.statusCode}, Тело: ${error.responseBodyAsString}") + } + is java.net.UnknownHostException -> { + logger.error("Не удалось разрешить хост для запроса TLE (NORAD ID: $noradId). " + + "Проверьте подключение к интернету.", error) + throw CustomErrorException("Не удалось разрешить хост для запроса TLE (NORAD ID: $noradId). " + + "Проверьте подключение к интернету.") + } + is java.net.SocketTimeoutException -> { + logger.error("Таймаут при запросе TLE для NORAD ID: $noradId", error) + throw CustomErrorException("Таймаут при запросе TLE для NORAD ID: $noradId") + } + else -> { + logger.error("Ошибка при запросе $format TLE для NORAD ID: $noradId", error) + throw CustomErrorException("Ошибка при запросе $format TLE для NORAD ID: $noradId") + } + } + } + + +// https://www.space-track.org/basicspacedata/query/class/gp_history/norad_cat_id/39634/CREATION_DATE/%3Enow-60/format/tle + fun checkCycle(id : Long){ + val bal = Ballistics() + val tles = tleRepository.findAllBySatelliteId(id) + .sortedBy { tle -> tle.timeTle } + var first = true + var vit = 0 + var tVuz = LocalDateTime.now() + var lVuz = 0.0 + for(t in tles){ + var s = t.tle.split("\r\n") + val r = bal.calculateOrbPoints(TLE(s[1],s[2]), 86400.0 * 10) + if (r != BallisticsError.OK) + throw CustomErrorException("Ошибка расчета прогноза") + if (first){ + first = false + vit = bal.revolutions.first().vuz.vit + lVuz = bal.revolutions.first().lVuz * 180.0 / PI + tVuz = toDateTime(bal.revolutions.first().vuz.t) + } else { + for (v in bal.revolutions){ + val dL = abs(v.lVuz* 180.0 / PI - lVuz) + val dvit = v.vuz.vit-vit + if (dL < 0.005){ + val dt : Long = Duration.between(tVuz, toDateTime(v.vuz.t)).seconds + logger.info("${tVuz} - ${toDateTime(v.vuz.t)} сдвиг витков ${abs(vit - v.vuz.vit)} : ${dL * PI / 180.0} : ${v.vuz.r.z} : $dt : ${dt/86400}") + } + } + } + } + } +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/ComplexMissionService.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/ComplexMissionService.kt new file mode 100644 index 0000000..4b91890 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/ComplexMissionService.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Flux +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO + + +@Service +class ComplexMissionService + (private val webClientBuilder: WebClient.Builder) { + + @Value("\${settings.satellite-catalog-service:\${settings.complex-mission-service:pcp-satellite-catalog-service}}") + val satelliteCatalogServiceURI = "pcp-satellite-catalog-service" + + + fun getSatellites(): Flux = + webClientBuilder.build() + .get() + .uri("$satelliteCatalogServiceURI/api/satellites") + .retrieve() + .bodyToFlux(SatelliteSummaryDTO::class.java) + .map { satellite -> + SatelliteInfoDTO( + noradId = satellite.id, + name = satellite.name, + red = satellite.visualization.red, + green = satellite.visualization.green, + blue = satellite.visualization.blue, + scanTLE = satellite.scanTle, + code = satellite.code, + typeCode = satellite.typeCode, + active = satellite.active + ) + } +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/SatelliteDeletedService.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/SatelliteDeletedService.kt new file mode 100644 index 0000000..4ae35e8 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/SatelliteDeletedService.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp.tle_monitoring_service.repository.TLERepository + +@Service +class SatelliteDeletedService( + private val tleRepository: TLERepository +) { + + private val logger = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun deleteSatelliteData(satelliteId: Long): Long { + val beforeDelete = tleRepository.countBySatelliteId(satelliteId) + val deleted = tleRepository.deleteAllBySatelliteId(satelliteId) + logger.info( + "Deleted TLE data for satellite: satelliteId={}, tleBeforeDelete={}, tleDeleted={}", + satelliteId, + beforeDelete, + deleted + ) + return deleted + } +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/TlePollingSettingsService.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/TlePollingSettingsService.kt new file mode 100644 index 0000000..20858f6 --- /dev/null +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/TlePollingSettingsService.kt @@ -0,0 +1,77 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp.tle_monitoring_service.configuration.CustomValidationException +import space.nstart.pcp.tle_monitoring_service.configuration.TlePollingProperties +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +data class TlePollingSettings( + val enabled: Boolean, + val timeoutSeconds: Long, +) + +data class UpdateTlePollingSettingsRequest( + val enabled: Boolean?, + val timeoutSeconds: Long?, +) + +@Service +class TlePollingSettingsService(properties: TlePollingProperties) { + private val logger = LoggerFactory.getLogger(this::class.java) + private val enabled = AtomicBoolean(properties.enabled) + private val timeoutSeconds = AtomicLong(validateTimeout(properties.timeoutSeconds)) + private val lastPollStartedAt = AtomicReference() + + fun current(): TlePollingSettings = + TlePollingSettings( + enabled = enabled.get(), + timeoutSeconds = timeoutSeconds.get(), + ) + + fun update(request: UpdateTlePollingSettingsRequest): TlePollingSettings { + val updatedEnabled = request.enabled + ?: throw CustomValidationException("enabled must be set") + val updatedTimeoutSeconds = validateTimeout( + request.timeoutSeconds ?: throw CustomValidationException("timeoutSeconds must be set") + ) + + val wasEnabled = enabled.getAndSet(updatedEnabled) + timeoutSeconds.set(updatedTimeoutSeconds) + if (!wasEnabled && updatedEnabled) { + lastPollStartedAt.set(null) + } + + val settings = current() + logger.info( + "TLE polling settings updated: enabled={}, timeoutSeconds={}", + settings.enabled, + settings.timeoutSeconds, + ) + return settings + } + + fun shouldStartPolling(now: Instant = Instant.now()): Boolean { + if (!enabled.get()) { + return false + } + + val lastStartedAt = lastPollStartedAt.get() ?: return true + return Duration.between(lastStartedAt, now).seconds >= timeoutSeconds.get() + } + + fun markPollingStarted(now: Instant = Instant.now()) { + lastPollStartedAt.set(now) + } + + private fun validateTimeout(value: Long): Long { + if (value <= 0) { + throw CustomValidationException("timeoutSeconds must be greater than zero") + } + return value + } +} diff --git a/services/tle-monitoring-service/src/main/resources/application.yaml b/services/tle-monitoring-service/src/main/resources/application.yaml new file mode 100644 index 0000000..8a2dbbc --- /dev/null +++ b/services/tle-monitoring-service/src/main/resources/application.yaml @@ -0,0 +1,18 @@ +spring: + application: + name: tle-monitoring-service + profiles: + default: local + config: + import: "configserver:" + cloud: + config: + 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:dev} + +tle: + polling: + enabled: ${TLE_POLLING_ENABLED:false} + timeout-seconds: ${TLE_POLLING_TIMEOUT_SECONDS:10800} diff --git a/services/tle-monitoring-service/src/main/resources/db/migration/V1__create_tle.sql b/services/tle-monitoring-service/src/main/resources/db/migration/V1__create_tle.sql new file mode 100644 index 0000000..3dd68d6 --- /dev/null +++ b/services/tle-monitoring-service/src/main/resources/db/migration/V1__create_tle.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS tle ( + tle_id BIGSERIAL PRIMARY KEY, + satellite_id BIGINT NOT NULL, + revolution BIGINT NOT NULL, + tle TEXT NOT NULL +); + +CREATE INDEX idx_tle_satellite_id ON tle(satellite_id); +CREATE INDEX idx_tle_revolution ON tle(revolution); \ No newline at end of file diff --git a/services/tle-monitoring-service/src/main/resources/db/migration/V2__update_tle.sql b/services/tle-monitoring-service/src/main/resources/db/migration/V2__update_tle.sql new file mode 100644 index 0000000..35e7fac --- /dev/null +++ b/services/tle-monitoring-service/src/main/resources/db/migration/V2__update_tle.sql @@ -0,0 +1,2 @@ +ALTER TABLE tle ADD COLUMN IF NOT EXISTS time_tle TIMESTAMP; +ALTER TABLE tle ADD COLUMN IF NOT EXISTS time_reс TIMESTAMP; \ No newline at end of file diff --git a/services/tle-monitoring-service/src/main/resources/db/migration/V3__normalize_time_rec_column.sql b/services/tle-monitoring-service/src/main/resources/db/migration/V3__normalize_time_rec_column.sql new file mode 100644 index 0000000..2e5da62 --- /dev/null +++ b/services/tle-monitoring-service/src/main/resources/db/migration/V3__normalize_time_rec_column.sql @@ -0,0 +1,13 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'tle' + AND column_name = 'time_reс' + ) THEN + ALTER TABLE tle RENAME COLUMN "time_reс" TO time_rec; + END IF; +END $$; + +ALTER TABLE tle ADD COLUMN IF NOT EXISTS time_rec TIMESTAMP; diff --git a/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/TleMonitoringServiceApplicationTests.kt b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/TleMonitoringServiceApplicationTests.kt new file mode 100644 index 0000000..6c0d1a6 --- /dev/null +++ b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/TleMonitoringServiceApplicationTests.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp.tle_monitoring_service + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class TleMonitoringServiceApplicationTests { + + @Test + fun contextLoads() { + } + +} diff --git a/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakServiceTest.kt b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakServiceTest.kt new file mode 100644 index 0000000..6be5612 --- /dev/null +++ b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakServiceTest.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.tle_monitoring_service.configuration.TlePollingProperties + +class CelesTrakServiceTest { + + @Test + fun `findExpired does not access satellite source when polling is disabled`() { + val pollingSettingsService = TlePollingSettingsService( + TlePollingProperties(enabled = false, timeoutSeconds = 30), + ) + val service = CelesTrakService(WebClient.builder().build(), pollingSettingsService) + + val result = service.findExpired().collectList().block().orEmpty() + + assertTrue(result.isEmpty()) + } +} diff --git a/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/SatelliteDeletedServiceTest.kt b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/SatelliteDeletedServiceTest.kt new file mode 100644 index 0000000..36a5828 --- /dev/null +++ b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/SatelliteDeletedServiceTest.kt @@ -0,0 +1,26 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp.tle_monitoring_service.repository.TLERepository + +class SatelliteDeletedServiceTest { + + @Test + fun `deleteSatelliteData removes TLE rows`() { + val tleRepository = mock(TLERepository::class.java) + val service = SatelliteDeletedService(tleRepository) + + `when`(tleRepository.countBySatelliteId(56756L)).thenReturn(4L) + `when`(tleRepository.deleteAllBySatelliteId(56756L)).thenReturn(4L) + + val deleted = service.deleteSatelliteData(56756L) + + assertEquals(4L, deleted) + verify(tleRepository).countBySatelliteId(56756L) + verify(tleRepository).deleteAllBySatelliteId(56756L) + } +} diff --git a/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/TlePollingSettingsServiceTest.kt b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/TlePollingSettingsServiceTest.kt new file mode 100644 index 0000000..8cb88e5 --- /dev/null +++ b/services/tle-monitoring-service/src/test/kotlin/space/nstart/pcp/tle_monitoring_service/service/TlePollingSettingsServiceTest.kt @@ -0,0 +1,86 @@ +package space.nstart.pcp.tle_monitoring_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp.tle_monitoring_service.configuration.CustomValidationException +import space.nstart.pcp.tle_monitoring_service.configuration.TlePollingProperties +import java.time.Instant + +class TlePollingSettingsServiceTest { + + @Test + fun `initializes settings from properties`() { + val service = TlePollingSettingsService( + TlePollingProperties(enabled = false, timeoutSeconds = 45), + ) + + val settings = service.current() + + assertFalse(settings.enabled) + assertEquals(45, settings.timeoutSeconds) + } + + @Test + fun `polling is disabled by default`() { + val service = TlePollingSettingsService(TlePollingProperties()) + + val settings = service.current() + + assertFalse(settings.enabled) + assertFalse(service.shouldStartPolling()) + } + + @Test + fun `updates polling settings`() { + val service = TlePollingSettingsService(TlePollingProperties()) + + val settings = service.update( + UpdateTlePollingSettingsRequest(enabled = false, timeoutSeconds = 60), + ) + + assertFalse(settings.enabled) + assertEquals(60, settings.timeoutSeconds) + assertFalse(service.shouldStartPolling()) + } + + @Test + fun `uses timeout between polling cycles`() { + val service = TlePollingSettingsService( + TlePollingProperties(enabled = true, timeoutSeconds = 30), + ) + val startedAt = Instant.parse("2026-05-13T10:00:00Z") + + assertTrue(service.shouldStartPolling(startedAt)) + + service.markPollingStarted(startedAt) + + assertFalse(service.shouldStartPolling(startedAt.plusSeconds(29))) + assertTrue(service.shouldStartPolling(startedAt.plusSeconds(30))) + } + + @Test + fun `rejects invalid timeout`() { + val service = TlePollingSettingsService(TlePollingProperties()) + + assertThrows(CustomValidationException::class.java) { + service.update(UpdateTlePollingSettingsRequest(enabled = true, timeoutSeconds = 0)) + } + } + + @Test + fun `starts immediately after polling is enabled`() { + val service = TlePollingSettingsService( + TlePollingProperties(enabled = true, timeoutSeconds = 30), + ) + val startedAt = Instant.parse("2026-05-13T10:00:00Z") + + service.markPollingStarted(startedAt) + service.update(UpdateTlePollingSettingsRequest(enabled = false, timeoutSeconds = 30)) + service.update(UpdateTlePollingSettingsRequest(enabled = true, timeoutSeconds = 30)) + + assertTrue(service.shouldStartPolling(startedAt.plusSeconds(1))) + } +} diff --git a/services/tle-monitoring-service/src/test/resources/application.yaml b/services/tle-monitoring-service/src/test/resources/application.yaml new file mode 100644 index 0000000..0ad1c2e --- /dev/null +++ b/services/tle-monitoring-service/src/test/resources/application.yaml @@ -0,0 +1,73 @@ +spring: + application: + name: tle-monitoring-service + cloud: + config: + enabled: false + kafka: + bootstrap-servers: 192.168.60.68:29092 + consumer: + group-id: pcp + auto-offset-reset: earliest + template: + default-topic: pcp.tle + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://192.168.60.68:5432/pcp_tle + username: postgres + password: password + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + lob: + non_contextual_creation: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + lifecycle.timeout-per-shutdown-phase: 40s + jackson: + default-property-inclusion: non_null + codec: + max-in-memory-size: 20MB + +springdoc: + swagger-ui: + enabled: true + layout: BaseLayout + path: /swagger/ui + api-docs: + enabled: true + path: /api-docs + +logging: + level: + .: ERROR + file: + name: ./logs/application.log + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + info: + enabled: true + +settings: + complex-mission-service: http://192.168.60.68:7002 + +tle: + polling: + enabled: false + timeout-seconds: 10800 + +server: + port: 7001 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..bc5adbf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,54 @@ +import org.gradle.kotlin.dsl.maven + +rootProject.name = "pcp" +rootProject.buildFileName = "build.gradle.kts" + +include(":config-repo") + +include(":libs:pcp-types-lib") +include(":libs:ballistics-lib") + +include(":services:tle-monitoring-service") +include(":services:slots-service") +include(":services:pcp-ballistics-service") +include(":services:pcp-satellite-catalog-service") +include(":services:pcp-request-service") +include(":services:pcp-route-processing-service") +include(":services:pcp-complex-mission-service") +include(":services:pcp-tgu-service") +include(":services:pcp-ui-service") +include(":services:pcp-stations-service") +include(":services:pcp-mission-planing-service") +include(":services:pcp-coverage-scheme-service") +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 { + mavenCentral() + gradlePluginPortal() + maven { + url = uri("https://repo.nstart.local/repository/maven-proxy/") + } + } + + val versions_kotlin : String by settings + val versions_spring_boot : String by settings + val versions_spring_dependency: String by settings + val versions_sonarqube : String by settings + val versions_buildtools : String by settings + + plugins { + kotlin("jvm") version versions_kotlin + kotlin("plugin.spring") version versions_kotlin + kotlin("plugin.jpa") version versions_kotlin + 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.graalvm.buildtools.native") version versions_buildtools + } +}