diff --git a/.devcontainer/base-dev/base-dev.Dockerfile b/.devcontainer/base-dev/base-dev.Dockerfile index fa829898e..3499485b3 100644 --- a/.devcontainer/base-dev/base-dev.Dockerfile +++ b/.devcontainer/base-dev/base-dev.Dockerfile @@ -187,7 +187,6 @@ RUN apt-get update \ protobuf-compiler \ python3-colcon-common-extensions \ python3-rosdep \ - python3-vcstool \ tzdata \ && apt-get autoremove -y \ && apt-get clean -y \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 352725477..124881e06 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,19 +20,6 @@ "postCreateCommand": "./setup.sh", // Set *default* container specific settings.json values on container create. "customizations": { - "codespaces": { - "repositories": { - "UBCSailbot/boat_simulator": {"permissions": "write-all"}, - "UBCSailbot/controller": {"permissions": "write-all"}, - "UBCSailbot/custom_interfaces": {"permissions": "write-all"}, - "UBCSailbot/local_pathfinding": {"permissions": "write-all"}, - "UBCSailbot/network_systems": {"permissions": "write-all"}, - "UBCSailbot/notebooks": {"permissions": "write-all"}, - "UBCSailbot/raye-local-pathfinding": {"permissions": "write-all"}, - "UBCSailbot/website": {"permissions": "write-all"}, - "UBCSailbot/virtual_iridium": {"permissions": "write-all"} - } - }, "vscode": { "settings": { "terminal.integrated.profiles.linux": { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cb5366a99..92fbd9730 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,5 +7,29 @@ # review when someone opens a pull request. * @patrick-5546 +# boat_simulator +/notebooks/boat_simulator @DFriend01 +/src/boat_simulator @DFriend01 + +# custom_interfaces +/src/custom_interfaces @UBCSailbot/soft-leads + +# controller +/notebooks/controller @DFriend01 +/src/controller @DFriend01 + +# diagnostics +/src/diagnostics @samdai01 + # docs /docs/ @DFriend01 + +# local_pathfinding +/notebooks/local_pathfinding @jamenkaye +/src/local_pathfinding @jamenkaye + +# network_systems +/src/network_systems @hhenry01 + +# website +/src/website @jahn18 diff --git a/.github/actions/ament-lint/action.yml b/.github/actions/ament-lint/action.yml deleted file mode 100644 index 79da9f142..000000000 --- a/.github/actions/ament-lint/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: 'Ament Lint' -description: 'Ament Lint using devcontainer' - -inputs: - linter: - description: 'The linter to run' - required: true - disable_vcs: - description: 'Whether to disable VCS cloning all repositories' - required: true - -runs: - using: "composite" - steps: - - name: Build Containers - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml build - - - name: Run Containers - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml up -d - - - name: Run Tests - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml exec -T sailbot-workspace /bin/bash -c "export LINTER=${{ inputs.linter}} && export DISABLE_VCS=${{ inputs.disable_vcs }} && cd /workspaces/sailbot_workspace && .github/actions/ament-lint/run.sh" diff --git a/.github/actions/checkout/action.yml b/.github/actions/checkout/action.yml deleted file mode 100644 index ffae33a44..000000000 --- a/.github/actions/checkout/action.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: 'Checkout Repositories' -description: 'Checkout the repositories depending on the inputs' - -inputs: - repository: - description: 'The repository name: github.event.repository.name' - required: true - -runs: - using: "composite" - steps: - - name: Checkout ROS package - if: ${{ inputs.repository != 'sailbot_workspace' }} - uses: actions/checkout@v4 - with: - repository: UBCSailbot/${{ inputs.repository }} - path: src/${{ inputs.repository }} - - - name: Checkout custom_interfaces ROS package - if: ${{ inputs.repository != 'sailbot_workspace' && inputs.repository != 'custom_interfaces' }} - uses: actions/checkout@v4 - with: - repository: UBCSailbot/custom_interfaces - path: src/custom_interfaces - - - name: Checkout network_systems ROS package - if: ${{ inputs.repository != 'sailbot_workspace' && inputs.repository == 'local_pathfinding' }} - uses: actions/checkout@v4 - with: - repository: UBCSailbot/network_systems - path: src/network_systems - - - name: Checkout website ROS package - if: ${{ inputs.repository != 'sailbot_workspace' && inputs.repository == 'local_pathfinding' }} - uses: actions/checkout@v4 - with: - repository: UBCSailbot/website - path: src/website - - - name: Checkout virtual_iridium repository - if: ${{ inputs.repository != 'sailbot_workspace' && (inputs.repository == 'network_systems' || inputs.repository == 'local_pathfinding') }} - uses: actions/checkout@v4 - with: - repository: UBCSailbot/virtual_iridium - path: src/virtual_iridium diff --git a/.github/actions/clang-tidy/action.yml b/.github/actions/clang-tidy/action.yml deleted file mode 100644 index e8dc13e15..000000000 --- a/.github/actions/clang-tidy/action.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: 'Clang-Tidy' -description: 'Clang-Tidy using devcontainer' - -inputs: - disable_vcs: - description: 'Whether to disable VCS cloning all repositories' - required: true - -runs: - using: "composite" - steps: - - name: Build Containers - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml build - - - name: Run Containers - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml up -d - - - name: Run Tests - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml exec -T sailbot-workspace /bin/bash -c "export DISABLE_VCS=${{ inputs.disable_vcs }} && cd /workspaces/sailbot_workspace && .github/actions/clang-tidy/run.sh" diff --git a/.github/actions/run-in-container/action.yml b/.github/actions/run-in-container/action.yml new file mode 100644 index 000000000..18925b06a --- /dev/null +++ b/.github/actions/run-in-container/action.yml @@ -0,0 +1,31 @@ +# documentation: https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions +name: 'Run in Container' +description: 'Run script using our Docker image' + +inputs: + script: + description: 'Script to run' + required: true + run-website: + description: 'Whether to run the website image' + required: false + default: 'false' + linter: + description: 'For the lint script, the linter to run' + required: false + +runs: + using: "composite" + steps: + - name: Run sailbot workspace image + shell: bash + run: docker compose -f .devcontainer/docker-compose.yml up -d + + - name: Run website image + if: inputs.run-website == 'true' + shell: bash + run: docker compose -f .devcontainer/docker-compose.yml -f .devcontainer/docker-compose.website.yml up -d + + - name: Run script inside sailbot workspace image + shell: bash + run: docker compose -f .devcontainer/docker-compose.yml exec -T sailbot-workspace /bin/bash -c "export LINTER=${{ inputs.linter }} && cd /workspaces/sailbot_workspace && .github/actions/run-in-container/${{ inputs.script }}.sh" diff --git a/.github/actions/ament-lint/run.sh b/.github/actions/run-in-container/ament-lint.sh similarity index 94% rename from .github/actions/ament-lint/run.sh rename to .github/actions/run-in-container/ament-lint.sh index aae11d7dc..82272605a 100755 --- a/.github/actions/ament-lint/run.sh +++ b/.github/actions/run-in-container/ament-lint.sh @@ -54,7 +54,7 @@ if [[ $LOCAL_RUN != "true" ]]; then fi # Exclude repos and files we don't want to lint -VALID_SRC_DIRS=$(ls src | grep -v -e virtual_iridium -e raye-local-pathfinding -e website -e notebooks -e polaris.repos) +VALID_SRC_DIRS=$(ls src | grep -v -e virtual_iridium -e website) lint_errors=0 # Loop over each directory and lint it diff --git a/.github/actions/clang-tidy/ament_clang_tidy.py b/.github/actions/run-in-container/ament_clang_tidy.py similarity index 100% rename from .github/actions/clang-tidy/ament_clang_tidy.py rename to .github/actions/run-in-container/ament_clang_tidy.py diff --git a/.github/actions/clang-tidy/run.sh b/.github/actions/run-in-container/clang-tidy.sh similarity index 61% rename from .github/actions/clang-tidy/run.sh rename to .github/actions/run-in-container/clang-tidy.sh index d3872d336..cbc6b47de 100755 --- a/.github/actions/clang-tidy/run.sh +++ b/.github/actions/run-in-container/clang-tidy.sh @@ -7,4 +7,4 @@ if [[ $LOCAL_RUN != "true" ]]; then ./build.sh RelWithDebInfo OFF fi -python3 .github/actions/clang-tidy/ament_clang_tidy.py build/compile_commands.json --jobs 8 +python3 .github/actions/run-in-container/ament_clang_tidy.py build/compile_commands.json --jobs 8 diff --git a/.github/actions/test/run.sh b/.github/actions/run-in-container/test.sh similarity index 100% rename from .github/actions/test/run.sh rename to .github/actions/run-in-container/test.sh diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml deleted file mode 100644 index 68241e1c7..000000000 --- a/.github/actions/test/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: 'Test' -description: 'Test using devcontainer' - -inputs: - disable_vcs: - description: 'Whether to disable VCS cloning all repositories' - required: true - -runs: - using: "composite" - steps: - # TODO: remove once monorepo and combine website docker compose into main one - - name: Setup Code - shell: bash - run: | - docker compose -f .devcontainer/docker-compose.yml up -d - docker compose -f .devcontainer/docker-compose.yml exec -T sailbot-workspace /bin/bash -c "export DISABLE_VCS=${{ inputs.disable_vcs }} && cd /workspaces/sailbot_workspace && ./setup.sh" - - - name: Run Containers - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml -f .devcontainer/docker-compose.website.yml up -d - - - name: Run Tests - shell: bash - run: docker compose -f .devcontainer/docker-compose.yml -f .devcontainer/docker-compose.website.yml exec -T sailbot-workspace /bin/bash -c "export DISABLE_VCS=${{ inputs.disable_vcs }} && cd /workspaces/sailbot_workspace && .github/actions/test/run.sh" diff --git a/.github/release.yml b/.github/release.yml index 4dda2b104..6ca55fbcb 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -7,27 +7,40 @@ changelog: - dependabot - imgbot + # emoji reference (outdated): https://gist.github.com/rxaviers/7360908 categories: - title: "Bug Fixes :beetle:" labels: - bug - - title: "Dev Container Configuration Changes :whale:" + - title: "Website Changes :computer:" labels: - - devcontainer + - web - - title: "VS Code Configuration Changes :computer:" + - title: "Pathfinding Changes :earth_americas:" labels: - - vscode + - path - - title: "GitHub Configuration Changes :octocat:" + - title: "Boat Simulator Changes :video_game:" labels: - - github + - sim - - title: "Docs Site Updates :book:" + - title: "Controller Changes :boat:" + labels: + - ctrl + + - title: "Network Systems Changes :artificial_satellite:" + labels: + - net + + - title: "Docs Site Changes :book:" labels: - docs - - title: "Other Changes :hammer_and_wrench:" + - title: "Infrastructure Changes :hammer_and_wrench:" + labels: + - infrastructure + + - title: "Other Changes" labels: - "*" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c31f157e0..ff8ad8f20 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: # https://github.com/gaurav-nelson/github-action-markdown-link-check markdown-link-check: runs-on: ubuntu-latest - timeout-minutes: 1 + timeout-minutes: 2 steps: - name: Checkout workspace uses: actions/checkout@v4 @@ -87,7 +87,7 @@ jobs: name: Deploy Docs version runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.action != 'closed' - needs: [markdownlint, markdown-link-check, markdown-link-redirection-check, docs-build] + needs: [docs-build] permissions: contents: write steps: diff --git a/.github/workflows/test_definitions.yml b/.github/workflows/test_definitions.yml deleted file mode 100644 index 36b800c29..000000000 --- a/.github/workflows/test_definitions.yml +++ /dev/null @@ -1,166 +0,0 @@ -name: Test Definitions - -on: - # Called by other workflows: https://docs.github.com/en/actions/using-workflows/reusing-workflows#creating-a-reusable-workflow - workflow_call: - inputs: - # The repository name: ${{ github.event.repository.name }} - repository: - required: true - type: string - # If true, runs the ROS tests and linters - # Set to true for ROS packages - ros-ci: - required: true - type: boolean - # If true, runs clang-tidy - # Takes a long time, so only set to true for C++ repositories - clang-tidy: - required: true - type: boolean - # If true, rebuilds our docs site https://ubcsailbot.github.io/docs/ - # Set to true if the docs site contains a snippet of a file in the repository - rebuild-docs: - required: true - type: boolean - # If true, runs the Markdown linters - # True by default, disable only if there will never be Markdown files in the repository - markdown-ci: - required: false - default: true - type: boolean - secrets: - # A GitHub token with access to our docs site https://ubcsailbot.github.io/docs/ - # If rebuild-docs is true: - # 1. Create the repository secret PAT_TOKEN (copy from Bitwarden) - # 2. Set to ${{ secrets.PAT_TOKEN }} - PAT_TOKEN: - required: false - -permissions: - contents: write - pull-requests: write - -jobs: - # Adapted from https://github.com/actions/toolkit/issues/1264#issuecomment-1770928498 - extract-metadata: - runs-on: ubuntu-latest - timeout-minutes: 1 - if: inputs.ros-ci || inputs.clang-tidy || inputs.markdown-ci - permissions: - actions: read - outputs: - caller-ref: ${{ steps.workflows-ref.outputs.caller-ref }} - steps: - - name: Get workflow reference - id: workflows-ref - run: | - ref=$(curl -L -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/UBCSailbot/${{ inputs.repository }}/actions/runs/${{ github.run_id }} | jq -r '.referenced_workflows[0] | .ref') - echo "caller-ref=$ref" >> $GITHUB_OUTPUT - echo "ref=$ref" - - colcon-test: - runs-on: ubuntu-latest - timeout-minutes: 10 - if: ${{ inputs.ros-ci }} - needs: [extract-metadata] - steps: - - name: Checkout workspace - uses: actions/checkout@v4 - with: - repository: UBCSailbot/sailbot_workspace - ref: ${{ needs.extract-metadata.outputs.caller-ref }} - - - name: Checkout repositories - uses: ./.github/actions/checkout/ - with: - repository: ${{ inputs.repository }} - - - name: Test - uses: ./.github/actions/test/ - with: - disable_vcs: ${{ inputs.repository != 'sailbot_workspace' && inputs.repository != 'custom_interfaces' }} - - ament-lint: - strategy: - fail-fast: false - matrix: - # mypy and ament_lint_common, except for copyright, cppcheck, cpplint, uncrustify, and pep257 - linter: [lint_cmake, flake8, mypy, xmllint] - name: ament_${{ matrix.linter }} - runs-on: ubuntu-latest - timeout-minutes: 5 - if: ${{ inputs.ros-ci }} - needs: [extract-metadata] - steps: - - name: Checkout workspace - uses: actions/checkout@v4 - with: - repository: UBCSailbot/sailbot_workspace - ref: ${{ needs.extract-metadata.outputs.caller-ref }} - - - name: Checkout repositories - uses: ./.github/actions/checkout/ - with: - repository: ${{ inputs.repository }} - - - name: Run linter - uses: ./.github/actions/ament-lint/ - with: - linter: ${{ matrix.linter }} - disable_vcs: ${{ inputs.repository != 'sailbot_workspace' && inputs.repository != 'custom_interfaces' }} - - clang-tidy: - runs-on: ubuntu-latest - timeout-minutes: 15 - if: inputs.clang-tidy - needs: [extract-metadata] - steps: - - name: Checkout workspace - uses: actions/checkout@v4 - with: - repository: UBCSailbot/sailbot_workspace - ref: ${{ needs.extract-metadata.outputs.caller-ref }} - - - name: Checkout repositories - uses: ./.github/actions/checkout/ - with: - repository: ${{ inputs.repository }} - - - name: Run linter - uses: ./.github/actions/clang-tidy/ - with: - disable_vcs: ${{ inputs.repository != 'sailbot_workspace' && inputs.repository != 'custom_interfaces' }} - - # Runs Sailbot Workspace CI - run-sailbot-workspace-ci: - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && inputs.repository != 'sailbot_workspace' && inputs.repository != 'docs' - steps: - - name: Dispatch Sailbot Workspace Tests workflow to run CI - # https://github.com/orgs/community/discussions/26323#discussioncomment-5600001 - run: | - gh workflow run tests.yml -R UBCSailbot/sailbot_workspace - sleep 5 - gh run watch --exit-status -R UBCSailbot/sailbot_workspace $(gh run list -R UBCSailbot/sailbot_workspace -w tests.yml -L1 --json databaseId --jq '.[0].databaseId') - env: - GH_TOKEN: ${{ secrets.PAT_TOKEN }} - - # Merges Dependabot PRs - # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions - merge-dependabot-pr: - runs-on: ubuntu-latest - if: github.actor == 'dependabot[bot]' - steps: - - name: Fetch metadata - id: metadata - uses: dependabot/fetch-metadata@v1 - with: - github-token: "${{ secrets.PAT_TOKEN }}" - - name: Enable auto-merge and approve PR - run: | - gh pr merge --auto --squash "$PR_URL" - gh pr review --approve "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.PAT_TOKEN}} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 76ed7f9fa..51b5f9e72 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,27 +4,51 @@ on: push: branches: - main - # sailbot_workspace only: raye branch used to run Raye's software - raye pull_request: workflow_dispatch: jobs: - # CI for all UBCSailbot repositories defined in one place - # Runs another workflow: https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow - test-definitions: - # sailbot_workspace: use locally-defined file - # other repositories: set to UBCSailbot/sailbot_workspace/.github/workflows/test_definitions.yml@ - uses: ./.github/workflows/test_definitions.yml - # see https://github.com/UBCSailbot/sailbot_workspace/blob/main/.github/workflows/test_definitions.yml - # for documentation on the inputs and secrets below - with: - repository: ${{ github.event.repository.name }} - ros-ci: true - clang-tidy: true - rebuild-docs: true - # If rebuild-docs is true: - # 1. Create the repository secret PAT_TOKEN (copy from Bitwarden) - # 2. Uncomment the 2 lines below - secrets: - PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + colcon-test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout workspace + uses: actions/checkout@v4 + + - name: Test + uses: ./.github/actions/run-in-container/ + with: + script: 'test' + run-website: 'true' + + ament-lint: + strategy: + fail-fast: false + matrix: + # mypy and ament_lint_common, except for copyright, cppcheck, cpplint, uncrustify, and pep257 + linter: [lint_cmake, flake8, mypy, xmllint] + name: ament_${{ matrix.linter }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout workspace + uses: actions/checkout@v4 + + - name: Run linter + uses: ./.github/actions/run-in-container/ + with: + script: 'ament-lint' + linter: ${{ matrix.linter }} + + clang-tidy: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout workspace + uses: actions/checkout@v4 + + - name: Run linter + uses: ./.github/actions/run-in-container/ + with: + script: 'clang-tidy' diff --git a/.gitignore b/.gitignore index 49222a8c5..fe0e98f87 100644 --- a/.gitignore +++ b/.gitignore @@ -3,12 +3,6 @@ /install/ /log/ -# vcstool packages -/src/* -!/src/integration_tests -!/src/global_launch -!/src/polaris.repos - # configuration files /.devcontainer/config/* !/.devcontainer/config/README.md diff --git a/.markdown-link-check.json b/.markdown-link-check.json index 701046055..6bcf2cd94 100644 --- a/.markdown-link-check.json +++ b/.markdown-link-check.json @@ -1,5 +1,11 @@ { "ignorePatterns": [ + { + "pattern": "^http://localhost:*" + }, + { + "pattern": "^https://cruel-carlota.pagodabox.com*" + }, { "pattern": "^https://github.com/UBCSailbot/.github/issues/templates/edit" }, diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 000000000..6c5e7e83a --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +/.github/CODEOWNERS +/src/virtual_iridium diff --git a/README.md b/README.md index 28d24b54c..52268fc2c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Sailbot Workspace -[![Build Images](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/build-images.yml/badge.svg)](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/build-images.yml) [![Tests](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/tests.yml/badge.svg)](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/tests.yml) +[![Docs Site](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/docs.yml/badge.svg)](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/docs.yml) +[![Build Images](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/build-images.yml/badge.svg)](https://github.com/UBCSailbot/sailbot_workspace/actions/workflows/build-images.yml) This repository will get you set up to develop UBCSailbot's software on VS Code. It is based on athackst's [vscode_ros2_workspace](https://github.com/athackst/vscode_ros2_workspace). @@ -9,7 +10,8 @@ This repository will get you set up to develop UBCSailbot's software on VS Code. ## Features An overview of Sailbot Workspace's features can be found below. -See [our docs site](https://ubcsailbot.github.io/docs/current/sailbot_workspace/run/) for how to use these features. +See [our docs site](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/workflow/) +for how to use these features. ### Style @@ -67,16 +69,16 @@ Our CI can be found in [`.github/workflows/`](https://github.com/UBCSailbot/sail ### Customization This repository supports user-specific configuration files. To set this up, see -[How to use your dotfiles](https://ubcsailbot.github.io/docs/current/sailbot_workspace/how_to/#use-your-dotfiles). +[How to use your dotfiles](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/how_to/#use-your-dotfiles). ### Run Raye's Software [Raye](https://www.ubcsailbot.org/discover-raye) was our previous project. Her software can be run in the [`raye` branch](https://github.com/UBCSailbot/sailbot_workspace/tree/raye) -following the instructions in [How to run Raye's software](https://ubcsailbot.github.io/docs/main/current/sailbot_workspace/how_to/#run-rayes-software). +following the instructions in [How to run Raye's software](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/how_to/#run-rayes-software). The initial differences between the `main` and `raye` branches are summarized in [this PR](https://github.com/UBCSailbot/sailbot_workspace/pull/61). ## Documentation -Further documentation, including setup and run instructions, can be found on [our Docs website](https://ubcsailbot.github.io/docs/current/sailbot_workspace/overview/). +Further documentation, including setup and run instructions, can be found on [our Docs website](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/overview/). diff --git a/docs/current/boat_simulator/overview.md b/docs/current/boat_simulator/overview.md index d07941d49..4604f1a69 100644 --- a/docs/current/boat_simulator/overview.md +++ b/docs/current/boat_simulator/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Boat Simulator can be found in the - [boat_simulator](https://github.com/UBCSailbot/boat_simulator){target=_blank} GitHub repository. + The source code for Boat Simulator can be found in `src/boat_simulator`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/boat_simulator/main/README.md" +--8<-- "src/boat_simulator/README.md" diff --git a/docs/current/controller/overview.md b/docs/current/controller/overview.md index 6fc31bb62..80faae2c3 100644 --- a/docs/current/controller/overview.md +++ b/docs/current/controller/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Controller can be found in the - [controller](https://github.com/UBCSailbot/controller){target=_blank} GitHub repository. + The source code for Controller can be found in `src/controller`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/controller/main/README.md" +--8<-- "src/controller/README.md" diff --git a/docs/current/custom_interfaces/overview.md b/docs/current/custom_interfaces/overview.md index 27b487665..5a51816d4 100644 --- a/docs/current/custom_interfaces/overview.md +++ b/docs/current/custom_interfaces/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Custom Interfaces can be found in the - [custom_interfaces](https://github.com/UBCSailbot/custom_interfaces){target=_blank} GitHub repository. + The source code for Custom Interfaces can be found in `src/custom_interfaces`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/custom_interfaces/main/README.md" +--8<-- "src/custom_interfaces/README.md" diff --git a/docs/current/local_pathfinding/overview.md b/docs/current/local_pathfinding/overview.md index 7826d8ba7..eaa192a6c 100644 --- a/docs/current/local_pathfinding/overview.md +++ b/docs/current/local_pathfinding/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Local Pathfinding can be found in the - [local_pathfinding](https://github.com/UBCSailbot/local_pathfinding){target=_blank} GitHub repository. + The source code for Local Pathfinding can be found in `src/local_pathfinding`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/local_pathfinding/main/README.md" +--8<-- "src/local_pathfinding/README.md" diff --git a/docs/current/network_systems/overview.md b/docs/current/network_systems/overview.md index 6f820b851..a896780d3 100644 --- a/docs/current/network_systems/overview.md +++ b/docs/current/network_systems/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Network Systems can be found in the - [network_systems](https://github.com/UBCSailbot/network_systems){target=_blank} GitHub repository. + The source code for Network Systems can be found in `src/network_systems`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/network_systems/main/README.md" +--8<-- "src/network_systems/README.md" diff --git a/docs/current/notebooks/overview.md b/docs/current/notebooks/overview.md index 832917f40..3abec0b3d 100644 --- a/docs/current/notebooks/overview.md +++ b/docs/current/notebooks/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Notebooks can be found in the - [notebooks](https://github.com/UBCSailbot/notebooks){target=_blank} GitHub repository. + The source code for Notebooks can be found in `notebooks`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/notebooks/main/README.md" +--8<-- "notebooks/README.md" diff --git a/docs/current/sailbot_workspace/deployment.md b/docs/current/sailbot_workspace/deployment.md index c03613ff2..78a477951 100644 --- a/docs/current/sailbot_workspace/deployment.md +++ b/docs/current/sailbot_workspace/deployment.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The [source code for deployment](https://github.com/UBCSailbot/sailbot_workspace/tree/main/.devcontainer/deployment){target=_blank} - can be found in the [sailbot_workspace](https://github.com/UBCSailbot/sailbot_workspace){target=_blank} - GitHub repository. Its README has been copied below. + The source code for deployment can be found in `.devcontainer/deployment`. + Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/sailbot_workspace/main/.devcontainer/deployment/README.md" +--8<-- ".devcontainer/deployment/README.md" diff --git a/docs/current/sailbot_workspace/overview.md b/docs/current/sailbot_workspace/overview.md index 84e9faef9..5b96094e8 100644 --- a/docs/current/sailbot_workspace/overview.md +++ b/docs/current/sailbot_workspace/overview.md @@ -1,7 +1,5 @@ !!! quote "Source code" - The source code for Sailbot Workspace can be found in the - [sailbot_workspace](https://github.com/UBCSailbot/sailbot_workspace){target=_blank} GitHub repository. - Its README has been copied below. + The Sailbot Workspace README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/sailbot_workspace/main/README.md" +--8<-- "README.md" diff --git a/docs/current/sailbot_workspace/parameters.md b/docs/current/sailbot_workspace/parameters.md index 3b9f5b46a..db859c07d 100644 --- a/docs/current/sailbot_workspace/parameters.md +++ b/docs/current/sailbot_workspace/parameters.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The [README for ROS parameterization](https://github.com/UBCSailbot/sailbot_workspace/blob/main/src/global_launch/config/README.md){target=_blank} - can be found in the [sailbot_workspace](https://github.com/UBCSailbot/sailbot_workspace){target=_blank} - GitHub repository. Its README has been copied below. + Our ROS parameters can be found in `src/global_launch/config`. + Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/sailbot_workspace/main/src/global_launch/config/README.md" +--8<-- "src/global_launch/config/README.md" diff --git a/docs/current/sailbot_workspace/setup.md b/docs/current/sailbot_workspace/setup.md index 37d9e2a55..2f859dd3a 100644 --- a/docs/current/sailbot_workspace/setup.md +++ b/docs/current/sailbot_workspace/setup.md @@ -241,7 +241,7 @@ Click the popup to `Open Workspace`. If there isn't a popup: ## 7. Run the `setup` task -The `setup` task clones the repositories defined in `src/polaris.repos` and updates dependencies of the ROS packages. +The `setup` task updates dependencies of the ROS packages. If you don't know how to run a VS Code task, see [How to run VS Code commands, tasks, and launch configurations](./how_to.md#run-vs-code-commands-tasks-and-launch-configurations). ??? bug "Can't see the `setup` task" diff --git a/docs/current/sailbot_workspace/workflow.md b/docs/current/sailbot_workspace/workflow.md index 073893715..a7e222adf 100644 --- a/docs/current/sailbot_workspace/workflow.md +++ b/docs/current/sailbot_workspace/workflow.md @@ -54,15 +54,12 @@ If there are new features or bug fixes that you want to try, you will need to up However, there may be changes to the Dev Container that VS Code can't detect. To rebuild it yourself, run the `Dev Containers: Rebuild Container` VS Code command. -4. If you aren't working in any other branches, - run the `setup` task to switch the branches of all sub-repositories to their default specified in `src/polaris.repos` - and pull their latest changes -5. If you want to run our docs, website, or other optional programs, see [How to run optional programs](./how_to.md#run-optional-programs){target=_blank} +4. If you want to run our docs or website, see [How to work with containerized applications](./how_to.md#work-with-containerized-applications) ## 3. Make your changes -We make changes to our software following our [GitHub development workflow](https://ubcsailbot.github.io/docs/reference/github/workflow/overview/){target=_blank}. -Of particular relevance is the [Developing on Branches page](https://ubcsailbot.github.io/docs/reference/github/workflow/branches/){target=_blank}. +We make changes to our software following our [GitHub development workflow](https://ubcsailbot.github.io/sailbot_workspace/main/reference/github/workflow/overview/){target=_blank}. +Of particular relevance is the [Developing on Branches page](https://ubcsailbot.github.io/sailbot_workspace/main/reference/github/workflow/branches/){target=_blank}. !!! tip "Git interfaces" @@ -187,6 +184,7 @@ configurations in the **Run and Debug** tab on the VS Code primary sidebar. The If you are having some trouble running our software, here are some things you can try: +- Run the `setup` task to update package dependencies - Build from scratch 1. Run the `clean` task to delete C++ generated files 2. Run the `purge` task to delete ROS generated files diff --git a/docs/current/website/overview.md b/docs/current/website/overview.md index 102298480..927fe48fa 100644 --- a/docs/current/website/overview.md +++ b/docs/current/website/overview.md @@ -1,7 +1,6 @@ !!! quote "Source code" - The source code for Website can be found in the - [website](https://github.com/UBCSailbot/website){target=_blank} GitHub repository. + The source code for Website can be found in `src/website`. Its README has been copied below. ---8<-- "https://raw.githubusercontent.com/UBCSailbot/website/main/README.md" +--8<-- "src/website/README.md" diff --git a/docs/overrides/main.html b/docs/overrides/main.html index d75754906..c3eaa1a76 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -2,7 +2,7 @@ {% block outdated %} You're not viewing the version in the main branch. - + Click here to go to main. {% endblock %} diff --git a/docs/reference/ros.md b/docs/reference/ros.md index 139ecaee3..bb4181637 100644 --- a/docs/reference/ros.md +++ b/docs/reference/ros.md @@ -22,8 +22,9 @@ To get our workspace configuration running on your computer: 1. Set it up by following the [setup instructions](../current/sailbot_workspace/setup.md) 2. Uncomment the ROS 2 tutorials section in [`.devcontainer/Dockerfile`](https://github.com/UBCSailbot/sailbot_workspace/blob/main/.devcontainer/Dockerfile){target=_blank}, then run the "Dev Containers: Rebuild Container" VS Code command, to install the tutorials' dependencies -3. Uncomment the ROS 2 tutorials section in [`src/polaris.repos`](https://github.com/UBCSailbot/sailbot_workspace/blob/main/src/polaris.repos){target=_blank}, - then run the "setup" VS Code task, to clone the repositories used in the tutorials +3. Clone the repositories used in the tutuorials: [ros_tutorials](https://github.com/ros/ros_tutorials/tree/humble){target=_blank} + (`humble` branch), [py_pubsub_ex](https://github.com/UBCSailbot/py_pubsub_ex){target=_blank}, and [cpp_pubsub_ex](https://github.com/UBCSailbot/cpp_pubsub_ex){target=_blank}, + then run the `setup` VS Code task to install their dependencies Our workspace configuration contains easier methods of accomplishing some of the tutorial steps, or eliminates the need for them altogether. diff --git a/mkdocs.yml b/mkdocs.yml index 4d981e893..f5d24c27b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ # Project information site_name: UBCSailbot Software Team Docs -site_url: https://UBCSailbot.github.io/docs/ +site_url: https://UBCSailbot.github.io/sailbot_workspace/ site_author: UBCSailbot Software Team # Repository diff --git a/notebooks/README.md b/notebooks/README.md new file mode 100644 index 000000000..3d8ca3021 --- /dev/null +++ b/notebooks/README.md @@ -0,0 +1,10 @@ +# Notebooks + +UBC Sailbot's Jupyter notebooks for researching and exporing implementations. + +## Standards + +1. In addition to the dependencies installed in [Sailbot Workspace](https://github.com/UBCSailbot/sailbot_workspace), +notebooks may have additional dependencies that are installed in the first code block +2. Implementations in notebooks should be complete: do not import functions from other UBC Sailbot repositories +3. Notebooks should be organized into directories named like the UBC Sailbot repositories they correspond to diff --git a/notebooks/boat_simulator/PID_controller.ipynb b/notebooks/boat_simulator/PID_controller.ipynb new file mode 100644 index 000000000..3a358235f --- /dev/null +++ b/notebooks/boat_simulator/PID_controller.ipynb @@ -0,0 +1,726 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# **PID Controller Testing**\n", + "This notebook tests possible PID controller implementations that could be used in the boat simulator. Ran into issues due to earlier implementation that may not be consistent with Electrical Team planning. \n", + "\n", + "Old Ray Controller: https://github.com/UBCSailbot/raye-boat-controller/blob/master/python/tack_controller.py\n", + "\\\n", + "Calculations based on: https://core.ac.uk/download/pdf/79618904.pdf" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defaulting to user installation because normal site-packages is not writeable\n", + "Requirement already satisfied: matplotlib in /home/ros/.local/lib/python3.10/site-packages (3.8.2)\n", + "Requirement already satisfied: simple_pid in /home/ros/.local/lib/python3.10/site-packages (2.0.0)\n", + "Requirement already satisfied: control in /home/ros/.local/lib/python3.10/site-packages (0.9.4)\n", + "Requirement already satisfied: pillow>=8 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (10.2.0)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/lib/python3/dist-packages (from matplotlib) (2.4.7)\n", + "Requirement already satisfied: numpy<2,>=1.21 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (1.26.3)\n", + "Requirement already satisfied: cycler>=0.10 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (0.12.1)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib) (2.8.2)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (4.47.2)\n", + "Requirement already satisfied: packaging>=20.0 in /usr/lib/python3/dist-packages (from matplotlib) (21.3)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (1.4.5)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (1.2.0)\n", + "Requirement already satisfied: scipy>=1.3 in /home/ros/.local/lib/python3.10/site-packages (from control) (1.12.0)\n", + "Requirement already satisfied: six>=1.5 in /usr/lib/python3/dist-packages (from python-dateutil>=2.7->matplotlib) (1.16.0)\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install matplotlib simple_pid control\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import control as ctrl\n", + "import math\n", + "\n", + "# from boat_simulator.nodes.low_level_control.control import VanilaPID" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## PID Implementation \n", + "This class includes instances for VanilaPID, RudderPID and RobotPID. Additional Classes may be created by instantiating\n", + "a new error function that implements VanilaPID. This class represents the foundational PID class we created using the basic code architecture. The angle error calculation for the RudderPID is based on this post for calculating the signed delta angle: \n", + "https://stackoverflow.com/questions/1878907/how-can-i-find-the-smallest-difference-between-two-angles-around-a-point/2007279#2007279\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Low level control logic for actuating the rudder and the sail.\"\"\"\n", + "\n", + "from abc import ABC, abstractmethod\n", + "from math import atan2, cos, sin\n", + "from typing import Any, List\n", + "\n", + "from boat_simulator.common.types import Scalar\n", + "from boat_simulator.common.utils import bound_to_180\n", + "\n", + "\n", + "class PID(ABC):\n", + " \"\"\"Abstract class for a PID controller.\n", + "\n", + " Attributes:\n", + " `kp` (Scalar): The proportional component tuning constant.\n", + " `ki` (Scalar): The integral component tuning constant.\n", + " `kd` (Scalar): The derivative component tuning constant.\n", + " `time_period` (Scalar): Constant time period between error samples.\n", + " `buf_size` (int): The max number of error samples to store for integral component.\n", + " `error_timeseries` (List[Scalar]): Timeseries of error values computed over time.\n", + " `last_error` (float): The previous error calculated\n", + " `integral_sum` (int): The running total of integral sum\n", + "\n", + " \"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " kp: Scalar,\n", + " ki: Scalar,\n", + " kd: Scalar,\n", + " time_period: Scalar,\n", + " buf_size: int,\n", + " sum_threshold: Scalar,\n", + " ):\n", + " \"\"\"Initializes the class attributes. Note that this class cannot be directly instantiated.\n", + "\n", + " Args:\n", + " `kp` (Scalar): The proportional component tuning constant.\n", + " `ki` (Scalar): The integral component tuning constant.\n", + " `kd` (Scalar): The derivative component tuning constant.\n", + " `time_period` (Scalar): Time period between error samples.\n", + " `buf_size` (int): The max number of error samples to store for integral component.\n", + " `last_error` (float): The error calculated in the previous iteration\n", + " `integral_sum` (int): The running total of integral sum from integral response\n", + " \"\"\"\n", + " self.kp = kp\n", + " self.ki = ki\n", + " self.kd = kd\n", + " self.buf_size = buf_size\n", + " self.time_period = time_period\n", + " self.error_timeseries: List[Scalar] = list()\n", + " self.integral_sum: Scalar = 0\n", + " self.sum_threshold = sum_threshold\n", + "\n", + " def step(self, current: Any, target: Any) -> Scalar:\n", + " \"\"\"Computes the correction factor.\n", + "\n", + " Args:\n", + " `current` (Any): Current state of the system.\n", + " `target` (Any): Target state of the system.\n", + "\n", + " Returns:\n", + " Scalar: Correction factor.\n", + " \"\"\"\n", + "\n", + " error = self._compute_error(current, target)\n", + " feedback = (\n", + " self._compute_derivative_response(error)\n", + " + self._compute_integral_response(error)\n", + " + self._compute_proportional_response(error)\n", + " )\n", + " self.append_error(error)\n", + " return feedback\n", + "\n", + " def reset(self, is_latest_error_kept: bool = False):\n", + " \"\"\"Empties the error timeseries of the PID controller, effectively starting a new\n", + " control iteration.\n", + "\n", + " Args:\n", + " is_latest_error_kept (bool, optional): True if the latest error is kept in the error\n", + " timeseries to avoid starting from scratch if the target remains the same. False\n", + " if the timeseries should be completely emptied. Defaults to False.\n", + " \"\"\"\n", + " self.integral_sum = 0\n", + " self.error_timeseries.clear\n", + "\n", + " def append_error(self, error: Scalar) -> None:\n", + " \"\"\"Appends the latest error to the error timeseries attribute. If the timeseries is at\n", + " the maximum buffer size, the least recently computed error is evicted from the timeseries\n", + " and the new one is appended.\n", + "\n", + " Args:\n", + " `error` (Scalar): The latest error.\n", + " \"\"\"\n", + " if len(self.error_timeseries) < self.buf_size:\n", + " self.error_timeseries.append(error)\n", + " else:\n", + " self.integral_sum -= self.error_timeseries[0] * self.time_period\n", + " self.error_timeseries.pop(0)\n", + " self.error_timeseries.append(error)\n", + "\n", + " @abstractmethod\n", + " def _compute_error(self, current: Any, target: Any) -> Scalar:\n", + " \"\"\"Computes the currently observed error.\n", + "\n", + " Args:\n", + " current (Any): Current state of the system.\n", + " target (Any): Target state of the system.\n", + "\n", + " Returns:\n", + " Scalar: Current error between the current and target states.\n", + " \"\"\"\n", + " pass\n", + "\n", + " @abstractmethod\n", + " def _compute_proportional_response(self, error: Any) -> Scalar:\n", + " \"\"\"\n", + " Args:\n", + " error (Any): Current calculated error for present iteration\n", + "\n", + " Returns:\n", + " Scalar: The proportional component of the correction factor.\n", + " \"\"\"\n", + " pass\n", + "\n", + " @abstractmethod\n", + " def _compute_integral_response(self, error: Any) -> Scalar:\n", + " \"\"\"\n", + " Args:\n", + " error (Any): Current calculated error for present iteration\n", + " integral_sum (int): The running total of integral sum from integral response\n", + "\n", + " Returns:\n", + " Scalar: The integral component of the correction factor.\n", + " \"\"\"\n", + " pass\n", + "\n", + " @abstractmethod\n", + " def _compute_derivative_response(self, error: Any) -> Scalar:\n", + " \"\"\"\n", + " Args:\n", + " error (Any): Current calculated error for present iteration\n", + " last_error (float): The error calculated in the previous iteration\n", + "\n", + " Returns:\n", + " Scalar: The derivative component of the correction factor.\n", + " \"\"\"\n", + " pass\n", + "\n", + " @property\n", + " def last_error(self):\n", + " return self.error_timeseries[-1]\n", + "\n", + "\n", + "class VanilaPID(PID):\n", + " \"\"\"General Class for the PID controller.\n", + "\n", + " Extends: PID\n", + " \"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " kp: Scalar,\n", + " ki: Scalar,\n", + " kd: Scalar,\n", + " time_period: Scalar,\n", + " buf_size: int,\n", + " sum_threshold: Scalar,\n", + " ):\n", + " \"\"\"Initializes the class attributes.\n", + "\n", + " Args:\n", + " `kp` (Scalar): The proportional component tuning constant.\n", + " `ki` (Scalar): The integral component tuning constant.\n", + " `kd` (Scalar): The derivative component tuning constant.\n", + " `time_period` (Scalar): Time period between error samples.\n", + " `buf_size` (int): The max number of error samples to store for integral component.\n", + " `last_error` (float): The error calculated in the previous iteration\n", + " `integral_sum` (Scalar): The running total of integral sum from integral response\n", + " \"\"\"\n", + " super().__init__(\n", + " kp,\n", + " ki,\n", + " kd,\n", + " time_period,\n", + " buf_size,\n", + " sum_threshold,\n", + " )\n", + "\n", + " def _compute_proportional_response(self, error: Scalar) -> Scalar:\n", + " return self.kp * error\n", + "\n", + " def _compute_integral_response(self, error: Scalar) -> Scalar:\n", + " current_sum = self.integral_sum + (self.time_period * error)\n", + "\n", + " if abs(current_sum) < self.sum_threshold:\n", + " self.integral_sum = current_sum\n", + " else:\n", + " self.integral_sum = self.sum_threshold\n", + " return self.ki * self.integral_sum\n", + "\n", + " def _compute_derivative_response(self, error: Scalar) -> Scalar:\n", + " if not self.error_timeseries:\n", + " return 0\n", + " else:\n", + " derivative_response = (error - self.last_error) / self.time_period\n", + " return self.kd * derivative_response\n", + "\n", + "\n", + "class RudderPID(VanilaPID):\n", + " \"\"\"Individual Class for the rudder PID controller.\n", + "\n", + " Extends: VanilaPID\n", + " \"\"\"\n", + "\n", + " def _compute_error(self, current: Scalar, target: Scalar) -> Scalar:\n", + " error = atan2(sin(target - current), cos(target - current))\n", + " current_bound = bound_to_180(current)\n", + " target_bound = bound_to_180(target)\n", + " if current_bound == target_bound:\n", + " error = 0\n", + " return error\n", + "\n", + "\n", + "class RobotPID(VanilaPID):\n", + " \"\"\"Individual Class for the Model Robot Arm PID controller.\n", + "\n", + " Extends: VanilaPID\n", + " \"\"\"\n", + "\n", + " def _compute_error(self, current, target):\n", + " return target - current" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Graph Testing of PID Implementation\n", + "Uses transfer function: \n", + "$$([k_d, k_p, k_i], [1,0])$$\n", + "which relates to the function:\n", + "$$f(s) = \\frac{k_ds^2 + k_ps + k_i}{s}$$\n", + "$$L^{-1}\\{f(s)\\} = PID(t)$$\n", + "Use the inverse laplace transform:\n", + "$$L^{-1}\\{\\frac{1}{s^2 + 2s + 1}\\} = e^{-t}t$$\n", + "where $t$ is the feedback outputed from our PID controller. The final function output correlates to the new \n", + "position of the object after the plant process, which we feed back in to calculate the new error from the setpoint. The issues came with determining how our plant process utilizing this feedback to adjust the current model closer to the target value. \n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Robot Arm Test implementation for PID Controller\"\"\"\n", + "\n", + "from abc import ABC, abstractmethod\n", + "from math import exp\n", + "from typing import Any, List\n", + "\n", + "\n", + "from boat_simulator.common.types import Scalar\n", + "from boat_simulator.common.utils import bound_to_180\n", + "\n", + "GRAVITY = 9.8\n", + "\n", + "\n", + "class Plant:\n", + " # Private class member defaults\n", + "\n", + " __target_position: Scalar = 0.0\n", + " __current_position: Scalar = 0.0\n", + " __time_period: Scalar = 0.0\n", + " __position_log: List[Scalar] = list()\n", + " __time_log: List[Scalar] = list()\n", + "\n", + " def __init__(\n", + " self,\n", + " target_position: Scalar,\n", + " current_position: Scalar,\n", + " time_period: Scalar,\n", + " ):\n", + " self.__target_position = target_position\n", + " self.__current_position = current_position\n", + " self.__time_period = time_period\n", + " self.__position_log = []\n", + " self.__time_log = []\n", + "\n", + " def run(\n", + " self,\n", + " count: int,\n", + " controller: RobotPID,\n", + " ) -> Scalar:\n", + " running_time = 0\n", + " self.__position_log.append(0)\n", + " self.__time_log.append(0)\n", + " prev_position = self.__current_position\n", + " for _ in range(count):\n", + " feedback = controller.step(prev_position, self.__target_position)\n", + "\n", + " position = math.exp(-feedback) * feedback\n", + "\n", + " self.__position_log.append(position)\n", + " self.__time_log.append(running_time)\n", + "\n", + " prev_position = position\n", + " running_time += self.__time_period\n", + " return self.__time_log, self.__position_log" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Graph Testing using RobotPID\n", + "Graphs the output using tuning values. Our issue was finding the correct tuning values so that the output matched the expected PID controller graphs. This was the simplest model using error of $y_2 - y_1$. The graph did display damping oscillating behaviour as it settled at a constant value. The issue was our target was 0.1, whereas the setting position seems to finish at just over 0.08. Again, these tuning values were completely arbitrary through calculated guesses." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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oLq62un56upqJCYmunxPYmJin+3j4+Oh1WoxZswYpzajR4/G119/7fKYer0eer1ezqUrggELERFRYMjKsOh0OkyePBmFhYXSc1arFYWFhcjJyXH5npycHKf2ALB161apvU6nw9SpU1FeXu7U5siRIxg6dKicy+t3joXjWMNCRETkX7K3HM7Pz8f8+fMxZcoUZGVlYeXKlWhtbcWCBQsAAPPmzUNycjIKCgoAAAsXLsSMGTPw4osvYubMmVi3bh327NmD1157TTrm4sWLMXv2bFx33XW44YYbsGXLFnz88cfYvn27Mp9SIdLCccywEBER+ZXsgGX27Nmora3FsmXLYDQakZmZiS1btkiFtRUVFVCruxM306dPx7vvvounn34aTz31FEaMGIGNGzdi3LhxUps77rgDa9asQUFBAR599FGMGjUK77//Pq699loFPqJyOCREREQUGLLXYQlG/lqH5T8/PoC135zEQzcMx+K8jH47DxER0ZWg39ZhudJ1MMNCREQUEAxYZODS/ERERIHBgEUGFt0SEREFBgMWGdrNtkXuOCRERETkXwxYZOA6LERERIHBgEUGTmsmIiIKDAYsMrCGhYiIKDAYsMjQPUuIt42IiMif2PPK0NllK7plhoWIiMi/GLDI0Nlly7DotbxtRERE/sSeVwZHhkXPDAsREZFfMWDxkBACJseQEDMsREREfsWe10OO7ArADAsREZG/MWDxUKe5R8DCDAsREZFfsef1kKPgVq0CtGpVgK+GiIjoysKAxUM9pzSrVAxYiIiI/IkBi4c6zJzSTEREFCjsfT0kTWnWsuCWiIjI3xiweEhaNI7L8hMREfkde18POWYJGZhhISIi8jsGLB7qYIaFiIgoYNj7esiRYWHRLRERkf+x9/UQi26JiIgChwGLhxxFtwYOCREREfkde18PdZiZYSEiIgoUBiwekqY1s4aFiIjI79j7ekgquuWQEBERkd+x9/UQi26JiIgChwGLh6S9hJhhISIi8jv2vh5ihoWIiChwGLB4iEW3REREgcPe10OODIshhBkWIiIif2PA4iGphoUZFiIiIr9j7+uh7hoW3jIiIiJ/Y+/roe51WDgkRERE5G8MWDzkKLrVaXjLiIiI/I29r4fMFgGAQ0JERESBwN7XQ2aLbUhIx4CFiIjI79j7eshkD1hCOCRERETkd+x9PWSWAhZV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Xh5qampctt+xYwfmzJmD++67D/v27cOsWbMwa9YslJWVAQDa2tpQUlKCZ555BiUlJfjHP/6B8vJy3Hbbbb59MoVw4TgiIqLAUwkhhJw3ZGdnY+rUqVi1ahUAwGq1IiUlBY888giWLFlyUfvZs2ejtbUVmzZtkp6bNm0aMjMzsWbNGpfn2L17N7KysnDq1Cmkpqa6vaampiZER0ejsbERUVFRcj6OW2OWbUGbyYKvnrgBKQPCFD02ERHRlUxO/y0rbWAymbB3717k5uZ2H0CtRm5uLoqKily+p6ioyKk9AOTl5fXaHgAaGxuhUqkQExPj8vXOzk40NTU5PfoLMyxERESBJ6sXrqurg8ViQUJCgtPzCQkJMBqNLt9jNBplte/o6MCTTz6JOXPm9BptFRQUIDo6WnqkpKTI+RgeE0LALC0cx1lCREREgRJUaQOz2Yyf/vSnEELg1Vdf7bXd0qVL0djYKD0qKyv753os3aNlWmZYiIiIAkYrp3F8fDw0Gg2qq6udnq+urkZiYqLL9yQmJnrU3hGsnDp1Cp9//nmfY1l6vR56vV7OpftMxQQLERFRwMhKG+h0OkyePBmFhYXSc1arFYWFhcjJyXH5npycHKf2ALB161an9o5g5ejRo/jss88QFxcn57KIiIjoMicrwwIA+fn5mD9/PqZMmYKsrCysXLkSra2tWLBgAQBg3rx5SE5ORkFBAQBg4cKFmDFjBl588UXMnDkT69atw549e/Daa68BsAUrd911F0pKSrBp0yZYLBapvmXAgAHQ6XRKfVYiIiK6RMkOWGbPno3a2losW7YMRqMRmZmZ2LJli1RYW1FRAbW6O3Ezffp0vPvuu3j66afx1FNPYcSIEdi4cSPGjRsHADhz5gw++ugjAEBmZqbTubZt24brr7/ey49GRERElwvZ67AEo/5ah8XUZcXIp/8FANj/2xsRZQhR7NhERERXun5bh4WIiIgoEBiwEBERUdBjwEJERERBjwELERERBT0GLERERBT0GLAQERFR0GPAQkREREGPAQsREREFPQYsREREFPQYsBAREVHQY8BCREREQY8BCxEREQU9BixEREQU9BiwEBERUdBjwEJERERBjwELERERBT0GLERERBT0GLAQERFR0GPAQkREREGPAQsREREFPQYsREREFPQYsBAREVHQY8BCREREQY8BCxEREQU9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ej4xMREmkwkNDQ1O7Xve48TERJd/Bo7XyDbkU1NTg6uvvhparRZarRZffPEF/vCHP0Cr1SIhIYH3WSGDBw/GmDFjnJ4bPXo0KioqAHTfq76+NxITE1FTU+P0eldXF+rr63mv7RYvXowlS5bgnnvuwfjx43HvvffiscceQ0FBAQDe5/6g1D3tz+8SBiy90Ol0mDx5MgoLC6XnrFYrCgsLkZOTE8Aru3QIIfDwww/jgw8+wOeff35RmnDy5MkICQlxusfl5eWoqKiQ7nFOTg6+++47p38kW7duRVRU1EUdx5Xqhz/8Ib777juUlpZKjylTpmDu3LnSz7zPyrjmmmsumpp/5MgRDB06FACQnp6OxMREp3vd1NSEXbt2Od3rhoYG7N27V2rz+eefw2q1Ijs72w+fIvi1tbVBrXbunjQaDaxWKwDe5/6g1D3NycnBl19+CbPZLLXZunUrRo0a5dNwEABOa+7LunXrhF6vF2+++aY4ePCgeOCBB0RMTIzTTArq3S9/+UsRHR0ttm/fLs6ePSs92trapDYPPvigSE1NFZ9//rnYs2ePyMnJETk5OdLrjum2N954oygtLRVbtmwRAwcO5HRbN3rOEhKC91kpxcXFQqvViueee04cPXpUvPPOOyIsLEy8/fbbUpsVK1aImJgY8eGHH4r9+/eL22+/3eXU0EmTJoldu3aJr7/+WowYMeKKnm57ofnz54vk5GRpWvM//vEPER8fL5544gmpDe+zfM3NzWLfvn1i3759AoB46aWXxL59+8SpU6eEEMrc04aGBpGQkCDuvfdeUVZWJtatWyfCwsI4rdkfXnnlFZGamip0Op3IysoSO3fuDPQlXTIAuHysXbtWatPe3i5+9atfidjYWBEWFibuuOMOcfbsWafjnDx5Utx8880iNDRUxMfHi8cff1yYzWY/f5pLy4UBC++zcj7++GMxbtw4odfrRUZGhnjttdecXrdareKZZ54RCQkJQq/Xix/+8IeivLzcqc25c+fEnDlzREREhIiKihILFiwQzc3N/vwYQa2pqUksXLhQpKamCoPBIIYNGyZ+85vfOE2V5X2Wb9u2bS6/k+fPny+EUO6efvvtt+Laa68Ver1eJCcnixUrVihy/SoheiwdSERERBSEWMNCREREQY8BCxEREQU9BixEREQU9BiwEBERUdBjwEJERERBjwELERERBT0GLERERBT0GLAQERFR0GPAQkREREGPAQsREREFPQYsREREFPQYsBAREVHQ+/8Gq01DNMzg8wAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib import pyplot as plt\n", + "\n", + "dt = 1\n", + "\n", + "pid = RobotPID(kp=0.1, ki=0.1, kd=0.01, time_period=dt, buf_size=50, sum_threshold=100)\n", + "robot = Plant(target_position=0.1, current_position=0, time_period=dt)\n", + "x, y = robot.run(controller=pid, count=1000)\n", + "\n", + "plt.plot(x, y)\n", + "plt.axhline(y=0.1, color=\"r\", linestyle=\"--\")\n", + "# plt.plot(x, robot.forces)\n", + "\n", + "# function to show the plot\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Generated controller using TransferFunction from Python \"control\" library\n", + "Using the control library: https://pypi.org/project/control/, this was a generated code to test an known plant process (feedback function) and graphing the resulting output. The output was somewhat we thought the expected feedback graph should resemble. " + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Define the system\n", + "system = ctrl.TransferFunction([1], [1, 2, 1])\n", + "\n", + "# Define PID controller parameters\n", + "kp = 1.0\n", + "ki = 0.1\n", + "kd = 0.01\n", + "\n", + "# Create a PID controller\n", + "pid_controller = ctrl.TransferFunction([kd, kp, ki], [1, 0])\n", + "\n", + "# Connect the PID controller to the system\n", + "closed_loop_system = ctrl.feedback(system * pid_controller)\n", + "\n", + "# Time vector\n", + "time, response = ctrl.step_response(closed_loop_system)\n", + "\n", + "# Plot the response\n", + "\n", + "\n", + "plt.plot(time, response)\n", + "plt.xlabel(\"Time\")\n", + "plt.ylabel(\"Response\")\n", + "plt.title(\"PID Controller Response\")\n", + "plt.grid(True)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Notebook Testing of RobotArm Implementation\n", + "This was the first implementation that we tested using the original assumptions. The run functions calls a certain num_steps which acts as our iterative time step. This hopefully minimizes our error and we move toward the target value." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "class RobotArm:\n", + " def __init__(self, pid, mass, start_position, target_position, dt):\n", + " self.pid = pid\n", + " self.mass = mass\n", + " self.start_position = start_position\n", + " self.target_position = target_position\n", + " self.dt = dt\n", + "\n", + " self.times = list()\n", + " self.positions = list()\n", + " self.forces = list()\n", + "\n", + " def run(self, num_steps, start_upward_force):\n", + " # Fill in values for time 0\n", + " self.times.append(0)\n", + " self.positions.append(self.start_position)\n", + " self.forces.append(0)\n", + "\n", + " # Define starting kinematics\n", + " prev_acceleration = 0\n", + " prev_velocity = 0\n", + " prev_position = self.start_position\n", + "\n", + " force = start_upward_force\n", + "\n", + " for i in range(num_steps):\n", + " current_time = (i + 1) * self.dt\n", + "\n", + " # Step 1: Get the feedback from PID and store the new force (but do not update yet)\n", + " feedback = self.pid.step(prev_position, self.target_position)\n", + "\n", + " # Step 2: Compute the new kinematics with previous info, but do not update yet\n", + " acceleration = self.__compute_acceleration(force)\n", + " velocity = self.__compute_velocity(prev_velocity, prev_acceleration)\n", + " position = self.__compute_position(prev_position, prev_velocity, prev_acceleration)\n", + "\n", + " # Step 3: Update the force\n", + " new_force = force - feedback\n", + " if new_force >= 0:\n", + " force = new_force\n", + " else:\n", + " force = 0\n", + "\n", + " # Step 4: Add all new data to arrays\n", + " self.times.append(current_time)\n", + " self.positions.append(position)\n", + " self.forces.append(force)\n", + "\n", + " # Step 5: Update the previous kinematic values to be the newly computed ones\n", + " prev_position = position\n", + " prev_velocity = velocity\n", + " prev_acceleration = acceleration\n", + "\n", + " return self.times, self.positions\n", + "\n", + " def __compute_acceleration(self, prev_upward_force):\n", + " downward_force = 9.81 * self.mass\n", + " return (downward_force - prev_upward_force) / self.mass\n", + "\n", + " def __compute_velocity(self, prev_velocity, prev_acceleration):\n", + " return prev_velocity + (prev_acceleration * self.dt)\n", + "\n", + " def __compute_position(self, prev_position, prev_velocity, prev_acceleration):\n", + " accel_comp = (prev_acceleration * (self.dt**2)) / 2\n", + " return prev_position + (prev_velocity * self.dt) + accel_comp" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# OSCILLATING GRAPH CREATED!\n", + "Was able to create an oscillating graph again using arbitrary, educated guessing kp, ki, and kd values. " + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib import pyplot as plt\n", + "\n", + "dt = 0.1\n", + "\n", + "pid = RobotPID(kp=0.01, ki=0, kd=0.1, time_period=dt, buf_size=50, sum_threshold=10)\n", + "robot = RobotArm(\n", + " pid=pid,\n", + " mass=1,\n", + " start_position=1,\n", + " target_position=0,\n", + " dt=dt,\n", + ")\n", + "\n", + "breakpoint()\n", + "\n", + "x, y = robot.run(num_steps=500, start_upward_force=0)\n", + "\n", + "plt.plot(x, y)\n", + "# plt.plot(x, robot.forces)\n", + "\n", + "# function to show the plot\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Unknown Testing\n", + "This implementation was in our original notebook as well, but was one of the first iterations of the controller. Seems to have certain errors, but could be useful to look back in the future and see what worked and what didn't." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Robot Arm Test implementation for PID Controller\"\"\"\n", + "\n", + "from abc import ABC, abstractmethod\n", + "from typing import Any, List\n", + "\n", + "\n", + "from boat_simulator.common.types import Scalar\n", + "from boat_simulator.common.utils import bound_to_180\n", + "\n", + "GRAVITY = 9.8\n", + "\n", + "\n", + "class RobotArm:\n", + " # Private class member defaults\n", + " __mass: float = 0.0\n", + " __target_position: Scalar = 0.0\n", + " __current_position: Scalar = 0.0\n", + " __time_period: Scalar = 0.0\n", + " __position_log: List[Scalar] = list()\n", + " __time_log: List[Scalar] = list()\n", + "\n", + " def __init__(\n", + " self,\n", + " mass: float,\n", + " target_position: Scalar,\n", + " current_position: Scalar,\n", + " time_period: Scalar,\n", + " position_log: list,\n", + " time_log: list,\n", + " ):\n", + " self.__mass = mass\n", + " self.__target_position = target_position\n", + " self.__current_position = current_position\n", + " self.__time_period = time_period\n", + " self.__position_log = position_log\n", + " self.__time_log = time_log\n", + "\n", + " def run(\n", + " self,\n", + " count: int,\n", + " controller: RobotPID,\n", + " force: float,\n", + " velocity: float,\n", + " ) -> Scalar:\n", + " weight = self.__mass * GRAVITY\n", + " running_time = 0\n", + " prev_position = self.__current_position\n", + " prev_velocity = 0\n", + " prev_acceleration = 0\n", + " for _ in range(count):\n", + " feedback = controller.step(prev_position, self.target_position)\n", + "\n", + " acceleration = (weight - force) / self.__mass\n", + "\n", + " velocity = prev_velocity + (prev_acceleration * self.__time_period)\n", + "\n", + " accel_comp = (prev_acceleration * (self.__time_period**2)) / 2\n", + "\n", + " position = prev_position + (prev_velocity * self.__time_period) + accel_comp\n", + "\n", + " self.__position_log.append(position)\n", + " self.__time_log.append(running_time)\n", + "\n", + " running_time += self.__time_period\n", + " force += feedback\n", + "\n", + " self.__current_position = position\n", + " prev_position = position\n", + " prev_velocity = velocity\n", + " prev_acceleration = acceleration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Current Progress\n", + "\n", + "With the current state of the PID controller, we met with the Electrical team to discuss whether they had a Transfer Function or Controller implementation in mind. They seemed to have done a lot of work in MatLab already, which would require large amounts of work to transfer into Python. In the end, we decided for the simulator, it would be easier to use Raye's controller code, which was more consistent and tested to give reliable results. The hope with the PID is to come back to this testing in the future for Polaris' actually controller as we still want to use a PID controller for the most optimal error optimization. This notebook documented all of our work and testing so far during the term and will hopefully be a resource in the future. Other resources to consult:\n", + "\n", + "https://www.ctrlaltftc.com/the-pid-controller/the-proportional-term\n", + "\\\n", + "https://electronics.stackexchange.com/questions/629032/pid-controller-implementation-in-python\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/controller/wingsail_controller_prototype/.gitignore b/notebooks/controller/wingsail_controller_prototype/.gitignore new file mode 100644 index 000000000..122700f2b --- /dev/null +++ b/notebooks/controller/wingsail_controller_prototype/.gitignore @@ -0,0 +1,2 @@ +# Directory generated by the animation section +frames/ diff --git a/notebooks/controller/wingsail_controller_prototype/prototype_wingsail_controller.ipynb b/notebooks/controller/wingsail_controller_prototype/prototype_wingsail_controller.ipynb new file mode 100644 index 000000000..391b0d5f7 --- /dev/null +++ b/notebooks/controller/wingsail_controller_prototype/prototype_wingsail_controller.ipynb @@ -0,0 +1,1180 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Wingsail Controller Prototype" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defaulting to user installation because normal site-packages is not writeable\n", + "Requirement already satisfied: numpy in /home/ros/.local/lib/python3.10/site-packages (1.26.4)\n", + "Requirement already satisfied: scipy in /home/ros/.local/lib/python3.10/site-packages (1.12.0)\n", + "Requirement already satisfied: matplotlib in /home/ros/.local/lib/python3.10/site-packages (3.8.3)\n", + "Requirement already satisfied: packaging>=20.0 in /usr/lib/python3/dist-packages (from matplotlib) (21.3)\n", + "Requirement already satisfied: pillow>=8 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (10.2.0)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (1.4.5)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib) (2.8.2)\n", + "Requirement already satisfied: cycler>=0.10 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (4.49.0)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/lib/python3/dist-packages (from matplotlib) (2.4.7)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /home/ros/.local/lib/python3.10/site-packages (from matplotlib) (1.2.0)\n", + "Requirement already satisfied: six>=1.5 in /usr/lib/python3/dist-packages (from python-dateutil>=2.7->matplotlib) (1.16.0)\n", + "Hit:1 http://packages.ros.org/ros2/ubuntu jammy InRelease\n", + "Hit:2 http://security.ubuntu.com/ubuntu jammy-security InRelease \u001b[0m\u001b[33m\n", + "Hit:3 http://archive.ubuntu.com/ubuntu jammy InRelease \n", + "Hit:4 http://archive.ubuntu.com/ubuntu jammy-updates InRelease\n", + "Ign:5 http://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 InRelease\n", + "Hit:6 http://archive.ubuntu.com/ubuntu jammy-backports InRelease\n", + "Hit:7 http://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 Release\n", + "Reading package lists... Done\u001b[33m\n", + "Building dependency tree... Done\n", + "Reading state information... Done\n", + "267 packages can be upgraded. Run 'apt list --upgradable' to see them.\n", + "\u001b[1;33mW: \u001b[0mhttp://repo.mongodb.org/apt/ubuntu/dists/jammy/mongodb-org/6.0/Release.gpg: Key is stored in legacy trusted.gpg keyring (/etc/apt/trusted.gpg), see the DEPRECATION section in apt-key(8) for details.\u001b[0m\n", + "Reading package lists... Done\n", + "Building dependency tree... Done\n", + "Reading state information... Done\n", + "ffmpeg is already the newest version (7:4.4.2-0ubuntu0.22.04.1).\n", + "0 upgraded, 0 newly installed, 0 to remove and 267 not upgraded.\n" + ] + } + ], + "source": [ + "# Install Python dependencies\n", + "!pip3 install numpy scipy matplotlib\n", + "!sudo apt update && sudo apt install -y ffmpeg" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "%matplotlib inline\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import math\n", + "import random" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Function `compute_reynolds_number` Implementation\n", + "\n", + "It takes the apparent wind speed and the chord width to computes the Reynold's number. \n", + "\n", + "$$Re = \\frac{V_{app}c}{k}$$\n", + "\n", + "where $V_{app}$ is the apparent wind speed, $c$ is the chord width of the mainsail, and $k$ is the kinematic viscosity of air. The kinematic viscosity is a constant value of $1.5 \\times 10^{-5} \\frac{m^2}{s}$, and the chord width is a constant value of $14 cm$ obtained from mech's design.\n", + "\n", + "```python" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "CHORD_WIDTH_MAIN_SAIL = 0.14 # meters, trim tab chord width is not included\n", + "KINEMATIC_VISCOSITY = 0.000014207 # {m^2 / s at 10degC} and air density at 1.225 {kg / m^3}\n", + "\n", + "\n", + "def compute_reynolds_number(apparent_wind_speed):\n", + " \"\"\"\n", + " Computes the Reynolds number for the main sail.\n", + "\n", + " Parameters:\n", + " - apparent_wind_speed (float): The apparent wind speed in meters per second.\n", + "\n", + " Returns:\n", + " - reynolds_number (float): The computed Reynolds number for the main sail.\n", + " \"\"\"\n", + " reynolds_number = (apparent_wind_speed * CHORD_WIDTH_MAIN_SAIL) / KINEMATIC_VISCOSITY\n", + " return reynolds_number" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Function `compute_angle_of_attack` Implementation\n", + "\n", + "It takes the Reynolds number and uses a lookup table to find the angle of attack." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "def compute_angle_of_attack(reynolds_number, look_up_table):\n", + " \"\"\"\n", + " Computes the desired angle of attack based on Reynolds number and a lookup table.\n", + "\n", + " Parameters:\n", + " - reynolds_number (float): The Reynolds number.\n", + " - look_up_table: A 2D numpy array containing Reynolds numbers in the first column\n", + " and corresponding desired angles of attack in the second column.\n", + "\n", + " Returns:\n", + " - desired_alpha (float): The computed desired angle of attack based on the provided Reynolds number\n", + " and lookup table.\n", + " \"\"\"\n", + " desired_alpha = np.interp(reynolds_number, look_up_table[:, 0], look_up_table[:, 1])\n", + " return desired_alpha" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Function `compute_trim_tab_angle` Implementation\n", + "\n", + "It takes the desired alpha value and computes the resulting trim tab angle. \n", + "\n", + "Computes the trim tab angle based on the desired angle of attack and the apparent wind direction. `apparent_wind_direction` will use degrees and follow same convention of WindSensor.msg (positive angle mean CW rotation). Zero degrees means that the apparent wind is blowing from the bow to the stern of the boat. \n", + "\n", + "The reason that the trim tab is equal to the `desired_alpha` mirrored about the apparent wind direction is because the the wind will push the trim tab, effectively rotating the mainsail until the trim tab is in line with the apparent wind (The mainsail sits on a bearing and will not rotate unless the trim tab is being pushed by the wind). This means that if we want to rotate the mainsail x degrees, we need to rotate the trim tab -x degrees. " + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "def compute_trim_tab_angle(desired_alpha, apparent_wind_direction):\n", + " \"\"\"\n", + " Range: -180 < direction <= 180 for symmetry\n", + "\n", + " Parameters:\n", + " - desired_alpha (float): The desired angle of attack.\n", + " - apparent_wind_direction (float): The apparent wind direction in degrees.\n", + "\n", + " Returns:\n", + " - trim_tab_angle (float): The computed trim tab angle based on the provided desired angle of attack,\n", + " apparent wind direction, and boat direction.\n", + "\n", + " \"\"\"\n", + " return math.copysign(desired_alpha, apparent_wind_direction)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test `test_trim_tab_angle`:\n", + "\n", + "Takes in apparent wind speed, apparent wind direction and the look up table of reynolds values and angles of attack. It then computes the reynolds number, followed by the and the angle of attack (alpha value). It then computes the trim tab angle and prints it.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "def test_trim_tab_angle(apparent_wind_speed, apparent_wind_direction, look_up_table):\n", + " \"\"\"_summary_\n", + "\n", + " Parameters:\n", + " apparent_wind_speed (float): the apparent wind speed of the boat\n", + " apparent_wind_direction (float): apparent wind direction in degrees\n", + " look_up_table (float, float): a 2D array containing Reynolds numbers in the first column\n", + " and corresponding desired angles of attack in the second column\n", + " \"\"\"\n", + " reynolds = compute_reynolds_number(apparent_wind_speed)\n", + " alpha = compute_angle_of_attack(reynolds, look_up_table)\n", + " trim_tab_angle = compute_trim_tab_angle(alpha, apparent_wind_direction)\n", + "\n", + " print(\"Reynolds number: \", round(reynolds, 2))\n", + " print(\"Angle of attack: \", round(alpha, 2))\n", + " print(\"Trim tab angle: \", round(trim_tab_angle, 2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reynolds to Alpha Lookup Table\n", + "`lookup_table` is a table that pairs reynolds numbers with angles of attack. The reynolds numbers are the keys and the angles of attack are the values. This table is sourced from [MECH testing](https://docs.google.com/spreadsheets/d/1rQuq55-VvUJRCS3mtuV4XycXq11VGx2vdJTXsl3EVNs/edit#gid=0) and is likely more accurate then the computations that would be done instead.\n", + "\n", + "| Reynolds Number | Angle of Attack |\n", + "|-----------------|-----------------|\n", + "| 50,000 | 5.75 |\n", + "| 100,000 | 6.75 |\n", + "| 200,000 | 7.00 |\n", + "| 500,000 | 9.75 |\n", + "| 1,000,000 | 10.00 |\n", + "\n", + "## Different Apparent Wind Speeds and Apparent Wind Directions as Inputs\n", + "\n", + "The following tests print the trim tab angle, Reynolds number, and angle of attack based on the provided inputs.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Reynolds number: 139931.02\n", + "Angle of attack: 6.85\n", + "Trim tab angle: -6.85\n" + ] + } + ], + "source": [ + "apparent_wind_speed = 14.2 # m/s\n", + "apparent_wind_direction = -23 # degrees\n", + "look_up_table = np.array(\n", + " [[50000, 5.75], [100000, 6.75], [200000, 7], [500000, 9.75], [1000000, 10]]\n", + ") # reynolds number, angle of attack\n", + "\n", + "test_trim_tab_angle(apparent_wind_speed, apparent_wind_direction, look_up_table)" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Reynolds number: 985429.72\n", + "Angle of attack: 9.99\n", + "Trim tab angle: 9.99\n" + ] + } + ], + "source": [ + "apparent_wind_speed = 100 # m/s\n", + "apparent_wind_direction = 40 # degrees\n", + "look_up_table = np.array(\n", + " [[50000, 5.75], [100000, 6.75], [200000, 7], [500000, 9.75], [1000000, 10]]\n", + ") # reynolds number, angle of attack\n", + "\n", + "test_trim_tab_angle(apparent_wind_speed, apparent_wind_direction, look_up_table)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Boat Diagram" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Function definitions\n", + "`angle` and `windDirection` parameters are values given in degrees and uses left hand coordinate system where positive \n", + "angles represent clockwise rotation on the plot.\n", + "\n", + "`point` and `center` are 2 element arrays that represent a point on an XY cartesian plane. The plot used has the origin\n", + "of the cartesian plane in the middle of the plot. Rhe x and y axis are the horizontal and vertical axises respectively.\n", + "The first element represents the x value of the point while the second element represents the y value of the point.\n", + "\n", + "`obj` and `sail` are arrays of points (2 element arrays). These arrays represent objects that will be drawn on the plot.\n", + "These arrays are iterated over and have a line from one point element to the next point element. The `sail` array has\n", + "two sections, the main sail and the trim tab. The trim tab is represented by the last two points of the `sail` array.\n", + "This convention is why the method `rotate_trimtab` exists. " + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [], + "source": [ + "def rotatePointAroundPoint(point, center, angle):\n", + " \"\"\"\n", + " Returns a point represented by 2 element array (e.g. [x, y]) rotated around a center point\n", + " by angle in degrees. Rotation is done by translating point using center:\n", + " `translated_point` = `point` - `center`\n", + "\n", + " then applies the rotation matrix:\n", + " `rotated_translated_point` = `rotation_matrix` @ `translated_point`\n", + "\n", + " then translating the rotated point by center\n", + " `rotated_point` = `rotated_translated_point` + `center`\n", + "\n", + " Parameters:\n", + " point (array): Point that will be rotated.\n", + " center (array): point used as center of rotation for point.\n", + " angle (float): angle in degrees to rotate point.\n", + "\n", + " Returns:\n", + " rotated_point (array): Point that has been rotated.\n", + " \"\"\"\n", + "\n", + " point = np.array(point)\n", + " center = np.array(center)\n", + "\n", + " angle = math.radians(angle)\n", + "\n", + " rotation_matrix = np.array(\n", + " [[np.cos(angle), np.sin(angle)], [-1 * np.sin(angle), np.cos(angle)]]\n", + " )\n", + "\n", + " translated_point = point - center\n", + " rotated_point = rotation_matrix @ translated_point\n", + " rotated_point = rotated_point + center\n", + "\n", + " return rotated_point\n", + "\n", + "\n", + "def rotate_object_around_origin(obj, angle):\n", + " \"\"\"\n", + " Returns an object, represented by an array of points, with each point rotated around the\n", + " origin by angle degrees.\n", + "\n", + " Parameters:\n", + " obj (array): Array of points that represents an object.\n", + " angle (float): angle in degrees to rotate object.\n", + "\n", + " Returns:\n", + " obj (array): Array of points that represents an object, reach point rotated around the\n", + " origin.\n", + " \"\"\"\n", + " for i in range(len(obj)):\n", + " obj[i] = rotatePointAroundPoint(obj[i], [0, 0], angle)\n", + " return obj\n", + "\n", + "\n", + "def rotate_trimtab(sail, angle):\n", + " \"\"\"\n", + " Returns an sail object, represented by an array of points, with the last point rotated around\n", + " the second to last point by angle degrees.\n", + "\n", + " Parameters:\n", + " sail (array): Array of points that represents a sail object.\n", + " angle (float): angle in degrees to rotate last point of sail object.\n", + "\n", + " Returns:\n", + " sail (array): Array of points that represents a sail object, with the last point rotated\n", + " around the second to last point.\n", + " \"\"\"\n", + " sail[-1] = rotatePointAroundPoint(sail[-1], sail[-2], angle)\n", + " return sail\n", + "\n", + "\n", + "def drawWind(windDirection):\n", + " \"\"\"\n", + " Draws a wind arrow on a plot using the given wind angle in degrees following WindSensor.msg\n", + " convention.\n", + "\n", + " Parameters:\n", + " windDirection (float): Wind direction given in angles\n", + "\n", + " Returns:\n", + " none\n", + " \"\"\"\n", + " windTail = [0, 3]\n", + " windTail = rotatePointAroundPoint(windTail, [0, 0], windDirection)\n", + " windHead = [0, -2]\n", + " windHead = rotatePointAroundPoint(windHead, [0, 0], windDirection)\n", + " plt.arrow(\n", + " windTail[0],\n", + " windTail[1],\n", + " windHead[0],\n", + " windHead[1],\n", + " head_width=0.2,\n", + " head_length=0.3,\n", + " fc=\"black\",\n", + " ec=\"black\",\n", + " label=\"wind\",\n", + " )\n", + "\n", + "\n", + "def drawWindField(windDirection, ax=None):\n", + " \"\"\"\n", + " Draws a wind field on a plot using the given wind angle in degrees following WindSensor.msg\n", + " convention.\n", + "\n", + " Args:\n", + " windDirection (float): Wind direction given in angles\n", + " \"\"\"\n", + " if ax is None:\n", + " ax = plt.gca()\n", + "\n", + " x = np.linspace(-7, 7, 15)\n", + " y = np.linspace(-7, 7, 15)\n", + " X, Y = np.meshgrid(x, y)\n", + " U = np.sin(np.radians(windDirection)) * np.ones_like(X)\n", + " V = -1 * np.cos(np.radians(windDirection)) * np.ones_like(Y)\n", + " ax.quiver(X, Y, U, V, alpha=0.5, width=0.003, label=\"Wind Field\")\n", + "\n", + "\n", + "def drawBoat():\n", + " \"\"\"\n", + " Draws a boat on a plot with the bow of the boat pointing in the +y direction.\n", + "\n", + " Returns:\n", + " none\n", + " \"\"\"\n", + " boat = [[0, 2], [-1, 1], [-1, -2], [1, -2], [1, 1], [0, 2]]\n", + " x_values = []\n", + " y_values = []\n", + " for i in range(len(boat)):\n", + " x_values.append(boat[i][0])\n", + " y_values.append(boat[i][1])\n", + "\n", + " plt.plot(x_values, y_values, color=\"red\", alpha=0.9)\n", + "\n", + "\n", + "def drawSail(sail):\n", + " \"\"\"\n", + " Draws a sail on the plot using the points contained in the sail object (array of points).\n", + "\n", + " Parameters:\n", + " sail (array):Array of points that represents a sail object.\n", + "\n", + " Returns:\n", + " none\n", + " \"\"\"\n", + " x_values = []\n", + " y_values = []\n", + " for i in range(len(sail)):\n", + " x_values.append(sail[i][0])\n", + " y_values.append(sail[i][1])\n", + "\n", + " plt.plot(x_values[0:-3], y_values[0:-3], color=\"blue\", label=\"mainsail\")\n", + " plt.plot(x_values[-4:-2], y_values[-4:-2], color=\"black\", linestyle=\"dotted\")\n", + " plt.plot(x_values[-2:], y_values[-2:], color=\"green\", label=\"trimtab\")\n", + "\n", + "\n", + "def drawValues(windSpeed, windDirection, angleOfAttack, trimTabAngle, ax=None):\n", + " \"\"\"\n", + " Prints out inputs for controller onto top right of plot.\n", + "\n", + " Args:\n", + " windSpeed (float): Wind speed given in m/s\n", + " windDirection (float): Wind direction given in angles\n", + " angleOfAttack (float): Angle of attack given in degrees\n", + " trimTabAngle (float): Trim tab angle given in degrees\n", + " \"\"\"\n", + " if ax is None:\n", + " ax = plt.gca()\n", + "\n", + " values = (\n", + " f\"Wind Speed: {windSpeed:.2f} m/s\\n\"\n", + " + f\"Wind Direction: {windDirection:.2f} degrees\\n\"\n", + " + f\"Angle of Attack: {angleOfAttack:.2f} degrees\\n\"\n", + " + f\"Trim Tab Angle: {trimTabAngle:.2f} degrees\"\n", + " )\n", + "\n", + " ax.text(\n", + " 0,\n", + " 0,\n", + " values,\n", + " horizontalalignment=\"center\",\n", + " verticalalignment=\"center\",\n", + " bbox=dict(facecolor=\"white\", alpha=0.0),\n", + " fontsize=8,\n", + " )\n", + "\n", + "\n", + "def drawVectors(windDirection, sail_angle, ax=None):\n", + " if ax is None:\n", + " ax = plt.gca()\n", + "\n", + " base_point = np.array([0, -1])\n", + " windDirection = math.radians(windDirection)\n", + " sail_angle = math.radians(sail_angle)\n", + " rotation_matrix_wind = np.array(\n", + " [\n", + " [np.cos(windDirection), -np.sin(windDirection)],\n", + " [np.sin(windDirection), np.cos(windDirection)],\n", + " ]\n", + " )\n", + " rotation_matrix_sail = np.array(\n", + " [[np.cos(sail_angle), -np.sin(sail_angle)], [np.sin(sail_angle), np.cos(sail_angle)]]\n", + " )\n", + " wind_vector = rotation_matrix_wind @ base_point\n", + " sail_vector = rotation_matrix_sail @ base_point\n", + "\n", + " ax.quiver(\n", + " 0,\n", + " 0,\n", + " wind_vector[0],\n", + " wind_vector[1],\n", + " angles=\"xy\",\n", + " scale_units=\"xy\",\n", + " scale=1,\n", + " color=\"blue\",\n", + " label=\"Wind Vector\",\n", + " )\n", + " ax.quiver(\n", + " 0,\n", + " 0,\n", + " sail_vector[0],\n", + " sail_vector[1],\n", + " angles=\"xy\",\n", + " scale_units=\"xy\",\n", + " scale=1,\n", + " color=\"green\",\n", + " label=\"Sail Vector\",\n", + " )\n", + "\n", + " angle_of_attack = windDirection - sail_angle\n", + "\n", + " ax.text(\n", + " -1.75,\n", + " -1.5,\n", + " f\"Angle between Vectors: {math.degrees(abs(angle_of_attack)):.2f} deg\",\n", + " fontsize=8,\n", + " bbox=dict(facecolor=\"white\", alpha=1.0),\n", + " )\n", + "\n", + "\n", + "def showPlot():\n", + " \"\"\"\n", + " Prints out the plot.\n", + "\n", + " Returns:\n", + " none\n", + " \"\"\"\n", + " plt.xlim(-7, 7)\n", + " plt.ylim(-5, 5)\n", + " plt.axhline(0, color=\"black\", linewidth=0.5)\n", + " plt.axvline(0, color=\"black\", linewidth=0.5)\n", + " plt.xticks([])\n", + " plt.yticks([])\n", + "\n", + " plt.title(\"Wingsail Controller Visualization\")\n", + "\n", + " plt.legend(loc=\"upper left\")\n", + " plt.show()\n", + "\n", + "\n", + "def randomizeWindDirection(size):\n", + " \"\"\"Generates a series of wind directions randomly within the range [-180, 180].\n", + "\n", + " Args:\n", + " size (int): The number of random wind directions to generate.\n", + "\n", + " Returns:\n", + " list: A list containing the randomly generated wind directions.\n", + " \"\"\"\n", + " series = []\n", + " random_numbers = [int(random.uniform(-180, 180)) for _ in range(size)]\n", + " for i in range(1, len(random_numbers), 1):\n", + " step = 1\n", + " if random_numbers[i - 1] > random_numbers[i]:\n", + " step = -1\n", + " series = series + list(range(random_numbers[i - 1], random_numbers[i], step))\n", + "\n", + " return series\n", + "\n", + "\n", + "def preventInIrons(trim_tab_angle, apparent_wind_direction):\n", + " \"\"\"Adjusts the trim tab angle based on the apparent wind direction.\n", + "\n", + " If the apparent wind direction is within -45 to 45 degrees, the trim tab angle is set to 0.\n", + " Otherwise, the trim tab angle remains unchanged.\n", + "\n", + " Args:\n", + " trim_tab_angle (float): The current angle of the trim tab.\n", + " apparent_wind_direction (float): The direction of the apparent wind in degrees.\n", + "\n", + " Returns:\n", + " float: The adjusted trim tab angle.\n", + " \"\"\"\n", + "\n", + " if apparent_wind_direction > -45 and apparent_wind_direction < 45:\n", + " return 0\n", + " else:\n", + " return trim_tab_angle" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Diagram Construction\n", + "The fun section! \n", + "1. Input wind speed and wind direction in Input section \n", + "2. then press \"exceute cell and below\" to draw plot" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Input section" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [], + "source": [ + "# input section\n", + "apparent_wind_speed = 100 # m/s\n", + "wind_direction = 50 # degrees, follows WindSensor.msg convention\n", + "look_up_table = np.array(\n", + " [[50000, 5.75], [100000, 6.75], [200000, 7], [500000, 9.75], [1000000, 10]]\n", + ") # [reynolds number, angle of attack(degrees)]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Drawing section" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Wind speed: 100 m/s\n", + "Wind direction: 50 degrees\n", + "Reynolds number: 985429.72\n", + "Angle of attack: 9.99 degrees\n", + "Trim tab angle: 9.99 degrees\n" + ] + }, + { + "data": { + "image/png": "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PR9++KHT6hUREeuwdMvAq6++iru7O+XLlyckJCTVeXpH8vDwYMqUKcyePZvChQvTsmVLAD788EMuXbrEv/71L7p06UL//v0pWLBgqsePGjWKZcuWUblyZRYsWMDSpUspX7680+oVERHrsBnpGJwfFRVFYGAgkZGR5M2bN8V9165d49ixY4SHh+Pj4+O0QsUx9H5JWlq0aMG6detcXYaIONidvr9vZumWAREREVEYEBERsTyFAREREYtTGBAREbE4h4UBZw7LE8fR+yQiIre653kGvLy8cHNz4/Tp04SEhODl5YXNZnNEbeJAhmEQFxfHP//8g5ubm30SJBERkXsOA25uboSHh3PmzBlOnz7tiJrEifz8/ChWrFiK6yqIiIi1OWQGQi8vL4oVK0ZCQkKWTe8rGefu7o6Hh4dabkREJAWHTUecfKGeWy/WIyIiItmb2opFREQsTmFARETE4hQGRERELE5hQERExOIUBkRERCxOYUBERMTiFAZEREQsTmFARETE4hQGRERELE5hQERExOIUBkRERCxOYUBERMTiFAZEREQsTmFARETE4hQGRERELE5hQERExOIUBkRERCxOYUBERMTiFAZEREQsTmFARETE4hQGRERELE5hQERExOIUBkRERCxOYUBERMTiFAZEREQsTmFARETE4hQGRERELE5hQERExOIUBkRERCxOYUBERMTiFAZEREQsTmFARETE4hQGRKzu6lVITHR1FSLiQh6uLkBEXOjqVShVCi5eNJd9fV1dkYi4gFoGRKwqMREef/zG7caN1UIgYlEKAyJWlJQE//kPHDt2Y93Ro/Daa+Z9ImIpCgMiVmMY8PrrsHQpuLnB7NlQpYq5vGQJjBhhbiPpNnXqVAYMGMC5c+dcXYpIpigMiFiJYcDbb8PcuWCzwaRJ8NRTEBoKEyea23z0EbzzjgJBOnXo0IH+/fszZcoU3Nz0X6rkTOpAKGIlkybB9Onm8pgx8MwzN+5r29bsRDhkCEybBn5+MHCgK6rMEQzDoGjRopw6dQqAy5cvExgY6OKqRDJHYUDEKmbNgvfeM5dHjoQuXVJv89xzZiAYNQrefdccXdCnT5aWmRPExcXh7e1tv52QkIC7u7sLKxK5N2rTErGCefNg9Ghz+bXXoHfv22/bp4/ZuRDMUDB/vtPLy0nOnz9vDwKVK1fGMAwFAcnxFAZE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Hzp48+mAMmXKOKVGERG5NwoDclsxcTF0WNWBT498ig0bk5pOov/D/TO0D5vNxqRJk+jevTtVqlRxUqUiInIvFAYkTbcOHVzSeglPl3s63Y+PiYkhT5482Gw2AAUBEZFsTH0GJJUkI4kmi5rw45kfCfYL5quuX2UoCMTFxdGsWTOee+45rl696sRKRUTEERQGJBU3mxvjGo2jXHA5dvbcme45BJJt376dnTt3sm7dOk6dOuWkKkVExFF0mkDS1KhUI35+8Wc83DL+EXn00Uf54osvuHbtGvfff78TqhMREUdSGJDbykwQSPboo486sBIREXEmnSYQhzh9+jTt27fn3Llzri5FREQySC0D4hDdu3dn8+bNREdHs3HjRleXIyIiGaCWAXGISZMmUbNmTaZOnerqUkREJIPUMiAOUa5cOXbs2GGfV0BERHIOtQxIpu3atYtDhw7ZbysIiIjkTAoDkmlXrlyhWbNmfP/9964uRURE7oFOE0imPfbYY3z55ZeEhoa6uhQREbkHCgNyT8LDw11dgoiI3COdJhAREbE4hQERERGLUxgQERGxOIUBERERi1MYEBERsTiFAREREYtTGBAREbE4hQERERGLUxgQERGxOIUBERERi1MYEBERsTiFAREREYtTGBAREbE4hQERERGLUxgQERGxOIUBERERi1MYEBERsTiFAREREYtTGBAREbE4hQERERGLUxgQERGxOIUBERERi1MYEBERsTiFAREREYtTGBAREbE4hQERERGLUxgQERGxOIUBERERi1MYEBERsTiFAREREYtTGBAREbE4hQERERGLUxgQERGxOIUBERERi1MYEBERsTiFAREREYtTGBAREbE4hQERERGLUxgQERGxOIUBERERi/NIz0aGYQAQFRXl1GJExDXi4+P171skF0r+d538PX476QoD0dHRABQtWvQeyxKR7CowMNDVJYiIk0RHR9/x37jNuFtcAJKSkjh9+jQBAQHYbDaHFigiIiLOYRgG0dHRFC5cGDe32/cMSFcYEBERkdxLHQhFREQsTmFARETE4hQGRERELE5hQERExOIUBkRERCxOYUBERMTiFAZEREQs7v8Bh5SaegPu6lgAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Determine trimtab angle\n", + "reynolds = compute_reynolds_number(apparent_wind_speed)\n", + "a = compute_angle_of_attack(reynolds, look_up_table)\n", + "t = compute_trim_tab_angle(a, wind_direction)\n", + "\n", + "# print functions\n", + "print(\"Wind speed: \", apparent_wind_speed, \" m/s\")\n", + "print(\"Wind direction: \", wind_direction, \" degrees\")\n", + "print(\"Reynolds number: \", round(reynolds, 2))\n", + "print(\"Angle of attack: \", round(a, 2), \" degrees\")\n", + "print(\"Trim tab angle: \", round(t, 2), \" degrees\")\n", + "\n", + "# construction and manipulation of wingsail\n", + "wingsail = [[0, 2], [0, 0], [0, -3], [0, -5], [0, -3], [0, -5]]\n", + "wingsail = rotate_object_around_origin(wingsail, wind_direction)\n", + "wingsail = rotate_trimtab(wingsail, t)\n", + "wingsail = rotate_object_around_origin(wingsail, -t)\n", + "\n", + "# Drawing commands\n", + "drawWind(wind_direction)\n", + "drawBoat()\n", + "drawSail(wingsail)\n", + "showPlot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Animation section\n", + "\n", + "The following section of this notebook creates a visualization of the wingsail controller in action. In particular,\n", + "the animation shows how the trim tab angle changes with apparent wind velocity.\n", + "\n", + "The animation consists of a subplot. On the left-hand side, the boat and wingsail are animated as apparent wind\n", + "velocity changes. On the right-hand side, there are two plots. The first contains the visualization data over time,\n", + "and the second compares the main sail and wind vectors. Then angle between these two vectors represent the angle of\n", + "attack that is achieved by the wingsail controller." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [], + "source": [ + "class Shape2D:\n", + " def __init__(self, points):\n", + " self.points = np.array(points)\n", + "\n", + " def scale(self, scalar):\n", + " return Shape2D(scalar * self.points)\n", + "\n", + " def translate(self, translation):\n", + " return Shape2D(self.points + translation)\n", + "\n", + " def rotate(self, theta, centroid):\n", + " theta = np.radians(theta)\n", + " rotation_matrix = np.array(\n", + " [[np.cos(theta), np.sin(theta)], [-np.sin(theta), np.cos(theta)]]\n", + " )\n", + " points = (self.points - centroid) @ rotation_matrix + centroid\n", + " return Shape2D(points)\n", + "\n", + " def plot(self, ax=None):\n", + " if ax is None:\n", + " ax = plt.gca()\n", + " x = self.points[:, 0]\n", + " y = self.points[:, 1]\n", + " ax.plot(x, y, color=\"black\")\n", + "\n", + " @staticmethod\n", + " def plot_shapes(ax, *args):\n", + " for shape in args:\n", + " shape.plot(ax)\n", + "\n", + "\n", + "def get_symmetric_air_foil_points():\n", + " y = 0.2 - np.array(\n", + " [\n", + " 1.0,\n", + " 0.95,\n", + " 0.9,\n", + " 0.8,\n", + " 0.7,\n", + " 0.6,\n", + " 0.5,\n", + " 0.4,\n", + " 0.3,\n", + " 0.25,\n", + " 0.2,\n", + " 0.15,\n", + " 0.1,\n", + " 0.075,\n", + " 0.05,\n", + " 0.025,\n", + " 0.0125,\n", + " 0.005,\n", + " 0.0,\n", + " 0.005,\n", + " 0.0125,\n", + " 0.025,\n", + " 0.05,\n", + " 0.075,\n", + " 0.1,\n", + " 0.15,\n", + " 0.2,\n", + " 0.25,\n", + " 0.3,\n", + " 0.4,\n", + " 0.5,\n", + " 0.6,\n", + " 0.7,\n", + " 0.8,\n", + " 0.9,\n", + " 0.95,\n", + " 1.0,\n", + " ]\n", + " )\n", + " x = 2.5 * np.array(\n", + " [\n", + " 0.00095,\n", + " 0.00605,\n", + " 0.01086,\n", + " 0.01967,\n", + " 0.02748,\n", + " 0.03423,\n", + " 0.03971,\n", + " 0.04352,\n", + " 0.04501,\n", + " 0.04456,\n", + " 0.04303,\n", + " 0.04009,\n", + " 0.03512,\n", + " 0.0315,\n", + " 0.02666,\n", + " 0.01961,\n", + " 0.0142,\n", + " 0.0089,\n", + " 0.0,\n", + " -0.0089,\n", + " -0.0142,\n", + " -0.01961,\n", + " -0.02666,\n", + " -0.0315,\n", + " -0.03512,\n", + " -0.04009,\n", + " -0.04303,\n", + " -0.04456,\n", + " -0.04501,\n", + " -0.04352,\n", + " -0.03971,\n", + " -0.03423,\n", + " -0.02748,\n", + " -0.01967,\n", + " -0.01086,\n", + " -0.00605,\n", + " -0.00095,\n", + " ]\n", + " )\n", + " return np.hstack((x.reshape(-1, 1), y.reshape(-1, 1)))" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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oF69esjKysL+/fvx66+/olWrVujTp0+pjufo6Ij27dtj0aJFUKvVqFmzJvbt24fExMQi90lMTETfvn3RvXt3xMTEYMOGDRgxYoTu96JevXr49NNPERYWhps3b6J///6oXLkyEhMTsXPnTkyaNIlPkyMyEs74EpHRLF68GJ06dcKePXsQGhqK0NBQxMXF4e2330ZsbGyh9zIdM2YMABR6UZsp8PX1xZ9//olevXph3bp1mDJlClasWAGlUqn3o3KVSoUVK1boHugwfPhwvYv5SsrBwQFHjhzB5MmTsWfPHkybNg3/+9//4O3tXeR9bE+fPo0rV64UGwK17xX2+OKysrOzw6FDhzBy5EisX78eM2fORGpqKr7//ntR7uELAKtXr0ajRo2wdetWTJ06FR988AESEhLwn//8B9HR0S99+lxhNm3ahMDAQCxfvhxhYWGwsrLC77//XuT2W7ZsgY2NDWbNmoXffvsNISEhWLNmjd42s2bNwo4dO6BUKhEeHo53330XERER6NatG/r27VvqGomobBRCcT8bIyKS2NKlSzFjxgzcvHmzwBX5RFKaO3cuwsPD8fDhw5c+OISITANnfInIZAmCgDVr1qBDhw4MvUREVG5c40tEJicrKwsRERE4ePAgLly4gF9++UXqkoiIqAJg8CUik/Pw4UOMGDECzs7O+PDDD7kGkoiIRME1vkREREQkC1zjS0RERESywOBLRERERLLANb4vodFokJycjMqVK4v2yFEiIiIiEo8gCMjIyECNGjX0HpH+bwy+L5GcnFzkk46IiIiIyHQkJSUV+TAfgMH3pbSP1ExKSoKjo6PE1RiPWq3Gvn370K1bN1hZWUldjlljL8XBPoqHvRQPeykO9lE8cu1leno63N3dX/oodAbfl9Aub3B0dJRd8LW3t4ejo6Os/sMxBPZSHOyjeNhL8bCX4mAfxSP3Xr5sWSovbiMiIiIiWWDwJSIiIiJZYPAlIiIiIlngGl8iIiIiEeTn50OtVktag1qthqWlJZ4/f478/HxJaxGThYUFLC0ty31rWQZfIiIionLKzMzEnTt3IAiCpHUIggCVSoWkpKQK9/wBe3t7VK9eHdbW1mU+BoMvERERUTnk5+fjzp07sLe3h6urq6SBU6PRIDMzEw4ODsU+yMGcCIKA3NxcPHz4EImJifDy8irzuTH4EhEREZWDWq2GIAhwdXWFnZ2dpLVoNBrk5ubC1ta2wgRfALCzs4OVlRVu3bqlO7+yqDgdISIiIpJQRVtaYGrECPIMvkREREQkCwy+RERERCQLDL5EREREVMChQ4egUCiQlpb20m3nzp0LNzc3KBQK7Nq166Xb37x5EwqFAmfPni31Z5UHgy8RERGRjMXExMDCwgK9evXSG2/Tpg3u3bsHJyenYve/fPkywsPDsXLlSty7dw89evR46We6u7vj3r17aNSoUblqLy0GXyIiIiIZW7NmDaZOnYrDhw8jOTlZN25tbQ2VSlXkRXv5+fnQaDRISEgAAPTr1w8qlQo2NjYv/UwLCwuoVCpYWhr3BmNmF3yXL18ODw8P2Nraws/PD3FxcSXab/PmzVAoFOjfv79hCyQiIiJZEwQBWVlZkvwq7QM0MjMzsWXLFkyePBm9evXCunXrdO/9e/nBunXr4OzsjIiICPj6+sLGxgbjx49Hnz59ALy464I2JGs0GnzyySeoVasWbGxs0LRpU0RGRuqO/e+lDsZiVvfx3bJlC0JDQ7FixQr4+flhyZIlCAwMRHx8PKpVq1bkfjdv3sS7776Ldu3aGbFaIiIikqPs7Gw4ODhI8tnp6eml2n7r1q3w8fGBt7c3Ro0ahenTpyMsLKzIWd7s7GwsXLgQq1evxiuvvILq1aujY8eOCA4Oxr1793TbLV26FF9++SVWrlyJZs2aYe3atejbty8uXboELy+vcp1jeZjVjO9XX32FiRMnIjg4GL6+vlixYgXs7e2xdu3aIvfJz8/HyJEjER4eDk9PTyNWS0RERGTa1qxZg1GjRgEAunfvjqdPn+KPP/4ocnu1Wo3//e9/aNOmDby9veHo6AhnZ2cAgEqlgkqlAgAsXrwYH3zwAYYNGwZvb28sXLgQTZs2xZIlSwx9SsUymxnf3NxcnDp1CmFhYboxpVKJgIAAxMTEFLnfJ598gmrVqmHChAk4cuTISz8nJycHOTk5utfafzmp1Wqo1epynIF50Z6rnM7ZUNhLcbCP4mEvxcNeisPc+6h9cptGo4FGo4GtrW2pZ17FYmdnh8zMTF09xYmPj0dcXBx27NgBjUYDpVKJoKAgrF69Gu3bt9ftrz0vjUYDa2trNGrUSO/Y/9wOeJGdkpOT4e/vr7ddmzZtcP78ed2x/n3sf74ujEajgSAIUKvVsLCw0HuvpN8dswm+jx49Qn5+Ptzc3PTG3dzccOXKlUL3OXr0KNasWVOq9SMLFixAeHh4gfF9+/bB3t6+VDVXBFFRUVKXUGGwl+JgH8XDXoqHvRSHufbR0tISKpUKmZmZyM3NlbSWzMxMAEBGRsZLt/3222+Rl5eHWrVq6cYEQYCNjQ3mz5+P7Oxs3bGUSiWeP38OW1vbAsd+9uwZgL8nC7X/m52drfcPgNzcXOTl5SE9PV1XZ1ZWFtLT0wt8VmFyc3Px7NkzHD58GHl5eXrvafd/GbMJvqWVkZGB0aNH47vvvkPVqlVLvF9YWBhCQ0N1r9PT0+Hu7o5u3brB0dHREKWaJLVajaioKHTt2hVWVlZSl2PW2EtxsI/iYS/Fw16Kw9z7+Pz5cyQlJcHBwQG2traS1iIIAjIyMlC5cuViH6Gcl5eHrVu3YvHixejataveewMHDsRvv/0GHx8fAEDlypXh6OgIW1tbKBSKAnnIzs4OAHTjjo6OqFGjBs6ePat3a7M///wTrVq1gqOjo24NdKVKleDo6KibXNR+VmGeP38OOzs7tG/fvkCfSzrDbjbBt2rVqrCwsMD9+/f1xu/fv69bT/JPCQkJuHnzpu5KQ+DvKXhLS0vEx8ejXr16BfazsbEp9DYcVlZWZvkfY3nJ9bwNgb0UB/soHvZSPOylOMy1j/n5+VAoFFAqlUXOVhqLNuto6ynKnj178OTJE7z55psF7tM7aNAgfP/99/jiiy8AQHde2uP9+7iFjb/33nuYM2cO6tevj6ZNm+L777/H2bNnsXHjxgLHKux1YbR3jSjse1LS743ZBF9ra2u0aNEC0dHRuluSaTQaREdHIyQkpMD2Pj4+uHDhgt7Y7NmzkZGRgaVLl8Ld3d0YZRMRERGZnDVr1iAgIKDQh1MMGjQIixYtwvnz58t8/GnTpuHp06eYOXMmHjx4AF9fX0REREh6RwfAjIIvAISGhmLs2LFo2bIlWrdujSVLliArKwvBwcEAgDFjxqBmzZpYsGABbG1tCzwNRHvVobGfEkJERERkSn799dci32vdurXufsDTpk3TjY8bNw7jxo0rsH3//v0L3D9YqVRizpw5mDNnTqGf4eHhobdPx44dS30P4rIwq+A7dOhQPHz4EB9//DFSUlJ0N0PWXvB2+/ZtyX/EQERERESmyayCLwCEhIQUurQBePGEkeL882kkRERERCQvnB4lIiIiIllg8CUiIiIiWWDwJSIiIhKBMS7OkjMx+svgS0RERFQO2sfnSv3UtopO+3S28tzr2ewubiMiIiIyJZaWlrC3t8fDhw9hZWUl6R2mNBoNcnNz8fz58wpzpytBEJCdnY0HDx7A2dlZ9w+NsmDwJSIiIioHhUKB6tWrIzExEbdu3ZK0FkEQ8OzZM9jZ2RX7yGJz5OzsXOjTekuDwZeIiIionKytreHl5SX5cge1Wo3Dhw+jffv2Zvn456JYWVmVa6ZXi8GXiIiISARKpRK2traS1mBhYYG8vDzY2tpWqOArloqx+IOIiIiI6CUYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBbMLvguX74cHh4esLW1hZ+fH+Li4orc9rvvvkO7du1QpUoVVKlSBQEBAcVuT0REREQVl1kF3y1btiA0NBRz5szB6dOn0aRJEwQGBuLBgweFbn/o0CEMHz4cBw8eRExMDNzd3dGtWzfcvXvXyJUTERERkdTMKvh+9dVXmDhxIoKDg+Hr64sVK1bA3t4ea9euLXT7jRs34u2330bTpk3h4+OD1atXQ6PRIDo62siVExEREZHULKUuoKRyc3Nx6tQphIWF6caUSiUCAgIQExNTomNkZ2dDrVbDxcWlyG1ycnKQk5Oje52eng4AUKvVUKvVZaze/GjPVU7nbCjspTjYx9LLzc3F3bt3cffuXSQlJeHu3bu4c+cOkpKS8PjxY0RHR6N27dqoVauW7pdKpYKFhYXUpZsNfi/FwT6KR669LOn5KgRBEAxciyiSk5NRs2ZNHD9+HP7+/rrx999/H3/88QdiY2Nfeoy3334be/fuxaVLl2Bra1voNnPnzkV4eHiB8U2bNsHe3r7sJ0BEZEAPHz7EmTNncP78eaSkpODx48dIS0tDaf+IVyqVcHFxQdWqVVGzZk00a9YMTZo0QeXKlQ1UORFR+WVnZ2PEiBF4+vQpHB0di9zObGZ8y+vzzz/H5s2bcejQoSJDLwCEhYUhNDRU9zo9PV23Nri4RlY0arUaUVFR6Nq1K6ysrKQux6yxl+JgH/Xl5ubi8OHD2LdvH/bu3YvLly8Xup21tbXejG7NmjVRvXp1XLx4EQ4ODkhOTsadO3dw9+5dJCcnIz8/H48ePcKjR49w5coVREdHQ6lUolWrVujWrRsCAwPRsmVLKJVmtVLOYPi9FAf7KB659lL7E/qXMZvgW7VqVVhYWOD+/ft64/fv34dKpSp238WLF+Pzzz/H/v378dprrxW7rY2NDWxsbAqMW1lZyeoLpCXX8zYE9lIccu9jWloaVqxYgaVLlyIlJUU3rlQq8frrryMwMBBNmjSBu7s7atWqBVdXVygUCr1jqNVq7NmzBz179tTrZV5eHu7fv4+kpCQkJSUhLi4OkZGRuHjxImJjYxEbG4t58+bB19cX7777LkaMGFHon5dyJPfvpVjYR/HIrZclPVezCb7W1tZo0aIFoqOj0b9/fwDQXagWEhJS5H6LFi3C/PnzsXfvXrRs2dJI1RIRiSspKQlLlizBqlWrkJmZCQBwc3NDr1690L17dwQEBKBKlSrl+gxLS0vUrFkTNWvWxOuvv44hQ4bgiy++wJ07d7B3715ERkYiMjISf/31F8aPH4/Zs2dj+vTpmDRpEpycnMQ4TSIigzKrn1WFhobiu+++w/r163H58mVMnjwZWVlZCA4OBgCMGTNG7+K3hQsX4qOPPsLatWvh4eGBlJQUpKSk6P7SICIydffu3cPYsWPh6emJr776CpmZmWjYsCHWrVuH27dvY82aNRgyZEi5Q29xatWqhQkTJmDbtm24c+cOFi5ciOrVqyM5ORnvv/8+3N3dERYWhuzsbIPVQEQkBrMKvkOHDsXixYvx8ccfo2nTpjh79iwiIyPh5uYGALh9+zbu3bun2/7bb79Fbm4uBg8ejOrVq+t+LV68WKpTICIqsZ07d6Jx48b44YcfkJeXh44dO+K3337DhQsXMHbsWFhbWxu9JicnJ7z//vtITEzE2rVr4evri4yMDHz++edo0aIFTp8+bfSaiIhKymyWOmiFhIQUubTh0KFDeq9v3rxp+IKIiESWmZmJ6dOnY82aNQCAZs2aYcWKFWjdurXElf3NxsYGwcHBGDt2LCIiIjBlyhRcuXIFfn5+mDdvHt577z3eFo2ITI5ZzfgSEVV0J06cQNOmTbFmzRooFAp88MEHOHHihEmF3n9SKpXo378/zp8/j0GDBiEvLw9hYWHo1KkTbt26JXV5RER6GHyJiExAXl4ewsPD0bZtWyQkJMDd3R0HDx7E559/LsmShtJ65ZVXsG3bNnz//fdwcHDAkSNH8Nprr2Hjxo1Sl0ZEpMPgS0QksZs3b6Jdu3aYO3cu8vPzMWLECJw/fx4dOnSQurRSUSgUGDduHM6ePQt/f3+kp6dj1KhRGD58eInvsUlEZEgMvkREEkpMTES7du1w4sQJODk5YePGjdi4cSOcnZ2lLq3M6tWrh8OHD+OTTz6BhYUFNm/ejO7duyMjI0Pq0ohI5hh8iYgkcvv2bXTq1Al37txBgwYNcO7cOYwYMULqskRhaWmJjz76CEeOHEGVKlUQExOD3r17IysrS+rSiEjGGHyJiCRw9+5d3QVgXl5eiI6ORp06daQuS3T+/v7Yt28fHB0dcfjwYfTr1w/Pnj2TuiwikikGXyIiI0tJSUHnzp1x48YNeHp64sCBA6hevbrUZRlMy5YtERkZCQcHB0RHR2PgwIHIycmRuiwikiEGXyIiI3r48CG6dOmCq1evonbt2jhw4ABq1aoldVkG5+/vjz179sDe3h6RkZEYMmQIcnNzpS6LiGSGwZeIyEjy8vIwcOBA/PXXX6hZsyYOHjxYIZc3FKVdu3b49ddfYWtri19//RWhoaFSl0REMsPgS0RkJPPmzcPRo0dRuXJlREdHw9PTU+qSjK5z587Ytm0bAGD58uX45ZdfJK6IiOSEwZeIyAj++OMPfPrppwCAlStXwtvbW+KKpNO7d2+8++67AIDx48fjzp07EldERHLB4EtEZGCPHz/GyJEjodFoEBwcjOHDh0tdkuTmz5+Pli1bIjU1FSNHjkR+fr7UJRGRDDD4EhEZkCAIGD9+PO7evQtvb28sW7ZM6pJMgrW1NX766Sc4ODjg8OHDmD9/vtQlEZEMMPgSERnQjz/+iIiICFhbW2Pz5s2oVKmS1CWZjPr162PFihUAgPDwcJw7d07iioioomPwJSIyELVajfDwcADA3Llz0bRpU2kLMkEjR47E4MGDodFodL0iIjIUBl8iIgPZsGEDbty4AVdXV0ybNk3qckzWJ598AoVCgZ07d+Ls2bNSl0NEFRiDLxGRAajVat1dHN5//30ucShGgwYNMGzYMAAvQjARkaEw+BIRGcA/Z3snT54sdTkm76OPPuKsLxEZHIMvEZHIONtbepz1JSJjYPAlIhIZZ3vLhrO+RGRoDL5ERCLibG/ZcdaXiAyNwZeISESc7S0fzvoSkSEx+BIRiYSzveXHWV8iMiQGXyIikWzcuJGzvSL456zv+fPnpS6HiCoQBl8iIpFs2rQJAPDOO+9wtrccGjRogAEDBgAAfvrpJ4mrIaKKhMGXiEgEGRkZ+OOPPwAAgwcPlrga8zdo0CAAwG+//SZxJURUkTD4EhGJYP/+/cjNzUW9evXw6quvSl2O2evevTuUSiUuXLiAW7duSV0OEVUQDL5ERCLYvXs3AKB3795QKBQSV2P+XFxc0KZNGwCc9SUi8TD4EhGVk0ajwZ49ewC8CL4kDm0vtf+oICIqLwZfIqJyOn36NFJSUuDg4ID27dtLXU6FoQ2+Bw4cQFZWlsTVEFFFYHbBd/ny5fDw8ICtrS38/PwQFxdX7Pbbtm2Dj48PbG1t0bhxY92sDBGRWLQzkt26dYO1tbXE1VQcvr6+qFOnDnJycnDgwAGpyyGiCsCsgu+WLVsQGhqKOXPm4PTp02jSpAkCAwPx4MGDQrc/fvw4hg8fjgkTJuDMmTPo378/+vfvj4sXLxq5ciKqyLRrULnMQVwKhYLLHYhIVGYVfL/66itMnDgRwcHB8PX1xYoVK2Bvb4+1a9cWuv3SpUvRvXt3vPfee2jQoAHmzZuH5s2b47///a+RKyeiiurevXv4888/AQA9e/aUuJqK55/BVxAEiashInNnKXUBJZWbm4tTp04hLCxMN6ZUKhEQEICYmJhC94mJiUFoaKjeWGBgIHbt2lXk5+Tk5CAnJ0f3Oj09HcCLR5Gq1epynIF50Z6rnM7ZUNhLcZhqH3/99VcAQKtWreDi4mJy9RXGVHtZmDfeeAP29vZITk7GyZMn0axZM6lL0mNOvTRl7KN45NrLkp6v2QTfR48eIT8/H25ubnrjbm5uuHLlSqH7pKSkFLp9SkpKkZ+zYMEChIeHFxjft28f7O3ty1C5eYuKipK6hAqDvRSHqfVR+2QxT09Ps7uGwNR6WRRfX1/8+eefWLlyJfr27St1OYUyl16aOvZRPHLrZXZ2dom2M5vgayxhYWF6s8Tp6elwd3dHt27d4OjoKGFlxqVWqxEVFYWuXbvCyspK6nLMGnspDlPt46effgoAGDhwoNksdTDVXhYlJiYGf/75JywsLEyux+bWS1PFPopHrr3U/oT+Zcwm+FatWhUWFha4f/++3vj9+/ehUqkK3UelUpVqewCwsbGBjY1NgXErKytZfYG05HrehsBeisOU+igIAq5evQoAaNiwocnUVVKm1Mvi+Pr6AgCuXbtmsvWaSy9NHfsoHrn1sqTnajYXt1lbW6NFixaIjo7WjWk0GkRHR8Pf37/Qffz9/fW2B15M/Re1PRFRaaSkpCA9PR1KpRL169eXupwKy8fHBwAQHx8vcSVEZO7MZsYXAEJDQzF27Fi0bNkSrVu3xpIlS5CVlYXg4GAAwJgxY1CzZk0sWLAAAPDOO++gQ4cO+PLLL9GrVy9s3rwZf/75J1atWiXlaRBRBaENYnXr1i30J0UkDm9vbwAv7qCRnp4uq2VnRCQuswq+Q4cOxcOHD/Hxxx8jJSUFTZs2RWRkpO4Cttu3b0Op/HsSu02bNti0aRNmz56NDz/8EF5eXti1axcaNWok1SkQUQWivbBWOyNJhuHk5ASVSoWUlBTEx8ejVatWUpdERGbKrIIvAISEhCAkJKTQ9w4dOlRgbMiQIRgyZIiBqyIiOdLO+GpnJMlwvL29kZKSgitXrjD4ElGZmc0aXyIiU8MZX+PhOl8iEgODLxFRGXHG13i0PWbwJaLyYPAlIiqD58+f4+bNmwA442sM2h4X9cAiIqKSYPAlIiqDGzduQBAEODk5wdXVVepyKrxXX30VwIt7+QqCIHE1RGSuGHyJiMogLS0NwIuH6ygUCmmLkYGqVasCAHJycpCbmytxNURkrhh8iYjKIDMzEwBQqVIliSuRh3/2Wdt7IqLSYvAlIiqDrKwsAICDg4PElciDpaWl7iEh2t4TEZUWgy8RURlwxtf4tP/I4IwvEZUVgy8RURlwxtf4tP/I4IwvEZUVgy8RURlwxtf4OONLROXF4EtEVAba8MUZX+Nh8CWi8mLwJSIqAy51MD4udSCi8mLwJSIqAy51MD7O+BJReTH4EhGVgXbWkcHXeLS9ZvAlorJi8CUiKgNra2sA4FPEjEjba23viYhKi8GXiKgMXnnlFQDA48ePJa5EPh49egTg794TEZUWgy8RURlow5c2jJHhaf+RweBLRGXF4EtEVAac8TU+Bl8iKi8GXyKiMmDwNS5BEBh8iajcGHyJiMqgatWqABh8jSUzMxNqtRrA370nIiotBl8iojLgjK9xaddS29rawt7eXuJqiMhcMfgSEZWBNvimpaUhLy9P4moqPi5zICIxMPgSEZWBi4sLgBdrT588eSJxNRUfgy8RiYHBl4ioDCwtLeHk5ASAyx2MgcGXiMTA4EtEVEa8wM14tD3mhW1EVB4MvkREZcQL3IyHT20jIjEw+BIRlRGDr/FwqQMRiYHBl4iojBh8jYfBl4jEwOBLRFRGDL7Gw+BLRGJg8CUiKiNtCNOuPyXDYfAlIjEw+BIRlRHv6mA82n9c8K4ORFQeZhN8U1NTMXLkSDg6OsLZ2RkTJkxAZmZmsdtPnToV3t7esLOzQ+3atTFt2jQ8ffrUiFUTUUXGpQ7GwxlfIhKD2QTfkSNH4tKlS4iKisLu3btx+PBhTJo0qcjtk5OTkZycjMWLF+PixYtYt24dIiMjMWHCBCNWTUQVGYOvceTk5CArKwsAgy8RlY+l1AWUxOXLlxEZGYmTJ0+iZcuWAIBly5ahZ8+eWLx4MWrUqFFgn0aNGmHHjh261/Xq1cP8+fMxatQo5OXlwdKy8FPPyclBTk6O7nV6ejoAQK1WQ61Wi3laJk17rnI6Z0NhL8Vhin10dHQE8CL4mlJdL2OKvSxOSkoKAECpVMLe3t6k6ja3Xpoq9lE8cu1lSc/XLIJvTEwMnJ2ddaEXAAICAqBUKhEbG4sBAwaU6DhPnz6Fo6NjkaEXABYsWIDw8PAC4/v27YO9vX3pizdzUVFRUpdQYbCX4jClPqampgIAHjx4gF9++QVWVlYSV1Q6ptTL4ly/fh3Ai39oREZGSlxN4cyll6aOfRSP3HqZnZ1dou3MIvimpKSgWrVqemOWlpZwcXHRzQS8zKNHjzBv3rxil0cAQFhYGEJDQ3Wv09PT4e7ujm7duulmd+RArVYjKioKXbt2Nbu/zE0NeykOU+yjIAiYOXMmnjx5gtq1a6NZs2ZSl1QiptjL4qxduxYA0KJFC/Ts2VPiavSZWy9NFfsoHrn2UvsT+peRNPjOmjULCxcuLHaby5cvl/tz0tPT0atXL/j6+mLu3LnFbmtjYwMbG5sC41ZWVrL6AmnJ9bwNgb0Uh6n1sVmzZjhw4AAuXryI1q1bS11OqZhaL4ty/vx5AEDz5s1Ntl5z6aWpYx/FI7delvRcJQ2+M2fOxLhx44rdxtPTEyqVCg8ePNAbz8vLQ2pqKlQqVbH7Z2RkoHv37qhcuTJ27twpqy8BERmeNvieOXNG6lIqLG1vzWVGnYhMl6TB19XVFa6uri/dzt/fH2lpaTh16hRatGgBADhw4AA0Gg38/PyK3C89PR2BgYGwsbFBREQEbG1tRaudiAj4O4wx+BpGfn4+zp07B4DBl4jKzyxuZ9agQQN0794dEydORFxcHI4dO4aQkBAMGzZMd0eHu3fvwsfHB3FxcQBehN5u3bohKysLa9asQXp6OlJSUpCSkoL8/HwpT4eIKhBtGDt37hw0Go3E1VQ8165dQ3Z2Nuzt7eHl5SV1OURk5szi4jYA2LhxI0JCQtClSxcolUoMGjQI33zzje59tVqN+Ph43VV9p0+fRmxsLACgfv36esdKTEyEh4eH0WonoopL+5CczMxMXL9+Ha+++qrUJVUo2pn0Jk2awMLCQuJqiMjcmU3wdXFxwaZNm4p838PDA4Ig6F537NhR7zURkSFYWFjgtddeQ2xsLE6fPs3gK7LTp08D4DIHIhJHqZc6jB07FocPHzZELUREZonrfA2HF7YRkZhKHXyfPn2KgIAAeHl54bPPPsPdu3cNURcRkdlg8DUMQRAYfIlIVKUOvrt27cLdu3cxefJkbNmyBR4eHujRowe2b98uu8fjEREB+sGXS6zEk5SUhNTUVFhaWqJRo0ZSl0NEFUCZ7urg6uqK0NBQnDt3DrGxsahfvz5Gjx6NGjVqYMaMGbh27ZrYdRIRmazGjRvDwsICjx494k/BRKSd7fX19S30wUJERKVVrtuZ3bt3D1FRUYiKioKFhQV69uyJCxcuwNfXF19//bVYNRIRmTRbW1v4+voC4HIHMXGZAxGJrdTBV61WY8eOHejduzfq1KmDbdu2Yfr06UhOTsb69euxf/9+bN26FZ988okh6iUiMklc5ys+Bl8iElupb2dWvXp1aDQaDB8+HHFxcWjatGmBbTp16gRnZ2cRyiMiMg/NmjXDDz/8wOArIgZfIhJbqYPv119/jSFDhhT7+F9nZ2ckJiaWqzAiInPCGV9xPX78GElJSQBQ6AQLEVFZlHqpw+jRo4sNvUREcqQNZ7du3UJqaqq0xVQA2n9A1KtXD46OjhJXQ0QVRbkubiMiohecnJzg6ekJADh79qy0xVQAXOZARIbA4EtEJBIudxAPgy8RGQKDLxGRSBh8xcPgS0SGwOBLRCQSbUg7deqUxJWYt8zMTMTHxwNg8CUicTH4EhGJpHXr1lAoFLhy5QpSUlKkLsdsHT58GIIgoE6dOlCpVFKXQ0QVCIMvEZFIqlatiubNmwMAoqKiJK7GfO3btw8A0K1bN4krIaKKhsGXiEhEgYGBAIC9e/dKXIn50vZO20siIrEw+BIRiUg7SxkVFQWNRiNxNebn9u3buHLlCpRKJTp37ix1OURUwTD4EhGJyN/fHw4ODnjw4AHOnTsndTlmR7vMwc/PD1WqVJG4GiKqaBh8iYhEZG1tjU6dOgHgcoey4DIHIjIkBl8iIpFpQ5t29pJKJj8/H/v37wfAC9uIyDAYfImIRKYNvkePHkVmZqbE1ZiPkydPIi0tDc7OzmjVqpXU5RBRBcTgS0Qksnr16qFu3bpQq9X4448/pC7HbGhnyAMCAmBpaSlxNURUETH4EhGJTKFQ8LZmZaDtFZc5EJGhMPgSERmANrwx+JZMWloaYmNjATD4EpHhMPgSERlA586dYWFhgatXr+LmzZtSl2PyDhw4gPz8fHh7e6NOnTpSl0NEFRSDLxGRATg5OeH1118HwLs7lARvY0ZExsDgS0RkIFznWzKCIDD4EpFRMPgSERmINsRFR0cjLy9P4mpM17Vr13Dr1i1YW1ujQ4cOUpdDRBUYgy8RkYG0aNECLi4uePr0KeLi4qQux2RpZ3vbtm2LSpUqSVwNEVVkDL5ERAZiYWGBgIAAAFznWxxtb7jMgYgMzWyCb2pqKkaOHAlHR0c4OztjwoQJJX4ikiAI6NGjBxQKBXbt2mXYQomI/oG3NStebm4uDh48CIC3MSMiwzOb4Dty5EhcunQJUVFR2L17Nw4fPoxJkyaVaN8lS5ZAoVAYuEIiooK0YS4uLg5PnjyRuBrTc+zYMWRlZcHNzQ2vvfaa1OUQUQVnFsH38uXLiIyMxOrVq+Hn54e2bdti2bJl2Lx5M5KTk4vd9+zZs/jyyy+xdu1aI1VLRPQ3d3d3NGjQABqNBtHR0VKXY3K0yxy6du0KpdIs/koiIjNmFg9Dj4mJgbOzM1q2bKkbCwgIgFKpRGxsLAYMGFDoftnZ2RgxYgSWL18OlUpVos/KyclBTk6O7nV6ejoAQK1WQ61Wl+MszIv2XOV0zobCXorDnPvYtWtXXL58GREREejXr5/U5ZhUL3/99VcAQJcuXUyintIypV6aM/ZRPHLtZUnP1yyCb0pKCqpVq6Y3ZmlpCRcXF6SkpBS534wZM9CmTZtS/UWzYMEChIeHFxjft28f7O3tS150BREVFSV1CRUGeykOc+yjm5sbAGDHjh3o27cvrKysJK7oBal7mZSUhEuXLsHS0hJWVlbYs2ePpPWUh9S9rCjYR/HIrZfZ2dkl2k7S4Dtr1iwsXLiw2G0uX75cpmNHRETgwIEDOHPmTKn2CwsLQ2hoqO51eno63N3d0a1bNzg6OpapFnOkVqsRFRWFrl27msxf0uaKvRSHOfexe/fuWL58OZKTk6FUKtGzZ09J6zGVXmonGbp164agoCDJ6igPU+mluWMfxSPXXmp/Qv8ykgbfmTNnYty4ccVu4+npCZVKhQcPHuiN5+XlITU1tcglDAcOHEBCQgKcnZ31xgcNGoR27drh0KFDhe5nY2MDGxubAuNWVlay+gJpyfW8DYG9FIe59nHIkCFYunQpduzYgYEDB0pdDgBpeykIArZv3w4AGDZsmFn+nv6TuX4vTQ37KB659bKk5ypp8HV1dYWrq+tLt/P390daWhpOnTqFFi1aAHgRbDUaDfz8/ArdZ9asWXjzzTf1xho3boyvv/4affr0KX/xRESlMHToUCxduhQRERF49uwZ7OzspC5JUufPn0d8fDxsbGxMYt0zEcmDWVxC26BBA3Tv3h0TJ05EXFwcjh07hpCQEAwbNgw1atQAANy9exc+Pj66pyOpVCo0atRI7xcA1K5dG3Xr1pXsXIhInl5//XXUrl0bmZmZ+P3336UuR3Jbt24FAPTo0UNWy8iISFpmEXwBYOPGjfDx8UGXLl3Qs2dPtG3bFqtWrdK9r1arER8fX+LFzURExqRQKHTrWLds2SJxNdISBEHXg6FDh0pcDRHJiVnc1QEAXFxcsGnTpiLf9/DwgCAIxR7jZe8TERnS0KFDsXjxYuzevRtZWVmoVKmS1CVJ4vTp00hISICdnR169+4tdTlEJCNmM+NLRGTuWrRoAU9PT2RnZ2P37t1SlyMZ7Wxv79694eDgIHE1RCQnDL5EREaiUCh0P9qX63IHQRB063u5zIGIjI3Bl4jIiLRhb8+ePSW+72RFEhsbi1u3bsHBwUHy+xkTkfww+BIRGdFrr70Gb29v5OTkICIiQupyjE470923b1/Z39KNiIyPwZeIyIjkvNxBo9Fg27ZtALjMgYikweBLRGRk2tC3d+9ePHnyROJqjOfYsWO4e/cunJycEBgYKHU5RCRDDL5EREbm6+uLRo0aQa1WY9euXVKXYzTaGe7+/fsX+mh4IiJDY/AlIpKA3JY75OfnY/v27QC4zIGIpMPgS0QkAW34279/Px49eiRxNYb3xx9/4P79+3BxcUFAQIDU5RCRTDH4EhFJwMvLC82aNUN+fj5+/vlnqcsxOO3M9sCBA2FlZSVxNUQkVwy+REQSkctyB7VajR07dgDgMgcikhaDLxGRRIKCggAAhw4dwv379yWuxnAOHDiAx48fw9XVFR07dpS6HCKSMQZfIiKJ1K1bF61bt9a7v21FpJ3RHjx4MCwtLSWuhojkjMGXiEhCw4cPBwCsW7dO2kIMJCMjA1u3bgUADBs2TOJqiEjuGHyJiCQ0atQoWFtb49SpUzhz5ozU5Yhuy5YtyMrKwquvvop27dpJXQ4RyRyDLxGRhKpWrYoBAwYAAL777juJqxGf9pzefPNNKBQKiashIrlj8CUiktjEiRMBABs3bkR2drbE1Yjn/PnziIuLg6WlJcaOHSt1OUREDL5ERFLr1KkT6tati/T09Ap1kdvq1asBAP369UO1atUkroaIiMGXiEhySqUSb775JoCKs9zh2bNn+PHHHwH8PaNNRCQ1Bl8iIhMwbtw4WFhY4NixY7h8+bLU5ZTbzz//jLS0NNSuXZuPKCYik8HgS0RkAmrUqIFevXoB+HuJgDnTzlxPmDABFhYWEldDRPQCgy8RkYnQLglYv349cnJyJK6m7K5evYo//vgDSqUSwcHBUpdDRKTD4EtEZCK6d++OmjVr4vHjx/jll1+kLqfM1qxZA+DF+bi7u0tcDRHR3xh8iYhMhKWlpW6G1FwvcsvNzdU9hY4XtRGRqWHwJSIyIRMmTIBCocD+/fuRmJgodTmltnv3bjx48ABubm66NctERKaCwZeIyIR4eHiga9euAP5eMmBOtDPVwcHBsLKykrgaIiJ9DL5ERCZGe0/f77//Hnl5eRJXU3K3bt3C3r17AbyYuSYiMjUMvkREJqZfv36oWrUqkpOT8fvvv0tdTol9//33EAQBnTp1Qv369aUuh4ioAAZfIiITY21tjbFjxwIwn4vc8vPzsXbtWgC8qI2ITBeDLxGRCdIud/jtt99w9+5diat5ub179yIpKQkuLi4YMGCA1OUQERXKbIJvamoqRo4cCUdHRzg7O2PChAnIzMx86X4xMTHo3LkzKlWqBEdHR7Rv3x7Pnj0zQsVERGXn4+ODdu3aQaPR6G4PZsq0T5sbPXo0bG1tJa6GiKhwZhN8R44ciUuXLiEqKgq7d+/G4cOHMWnSpGL3iYmJQffu3dGtWzfExcXh5MmTCAkJgVJpNqdNRDKmnfVds2YNNBqNxNUULSUlBb/++iuAv2smIjJFZpEAL1++jMjISKxevRp+fn5o27Ytli1bhs2bNyM5ObnI/WbMmIFp06Zh1qxZaNiwIby9vREUFAQbGxsjVk9EVDaDBw+Gk5MTEhMTER0dLXU5RVq3bh3y8vLw+uuvo1GjRlKXQ0RUJEupCyiJmJgYODs7o2XLlrqxgIAAKJVKxMbGFrqe7MGDB4iNjcXIkSPRpk0bJCQkwMfHB/Pnz0fbtm2L/KycnBzk5OToXqenpwMA1Go11Gq1iGdl2rTnKqdzNhT2Uhxy7KOVlRVGjBiBb7/9FsuXL0fHjh1FOa6YvczPz8eqVasAvLh3r5x+fwB5fi8NgX0Uj1x7WdLzNYvgm5KSgmrVqumNWVpawsXFBSkpKYXuc+PGDQDA3LlzsXjxYjRt2hQ//PADunTpgosXL8LLy6vQ/RYsWIDw8PAC4/v27YO9vX05z8T8REVFSV1ChcFeikNufWzQoAEAICIiAmvXroVKpRLt2GL0MiYmBomJiahcuTKcnJywZ88eESozP3L7XhoK+ygeufUyOzu7RNtJGnxnzZqFhQsXFrvN5cuXy3Rs7Xq4t956C8HBwQCAZs2aITo6GmvXrsWCBQsK3S8sLAyhoaG61+np6XB3d0e3bt3g6OhYplrMkVqtRlRUFLp27cqnL5UTeykOOfdx9+7d2LdvHy5duoTx48eX+3hi9vKLL74AAEyePBkDBw4sd23mRs7fSzGxj+KRay+1P6F/GUmD78yZMzFu3Lhit/H09IRKpcKDBw/0xvPy8pCamlrk7Ef16tUBAL6+vnrjDRo0wO3bt4v8PBsbm0LXAFtZWcnqC6Ql1/M2BPZSHHLs48yZM7Fv3z58//33mDdvHpycnEQ5bnl7efLkSRw7dgxWVlZ45513ZPf78k9y/F4aAvsoHrn1sqTnKmnwdXV1haur60u38/f3R1paGk6dOoUWLVoAAA4cOACNRgM/P79C9/Hw8ECNGjUQHx+vN3716lX06NGj/MUTERlJ165d0bBhQ1y6dAmrV6/GzJkzpS4JAPD1118DAIYNG4YaNWpIXA0R0cuZxV0dGjRogO7du2PixImIi4vDsWPHEBISoveH7d27d+Hj44O4uDgAgEKhwHvvvYdvvvkG27dvx/Xr1/HRRx/hypUrfIY8EZkVhUKBGTNmAAC++eYb5OXlSVwRkJSUhK1btwKArjYiIlNnFsEXADZu3AgfHx906dIFPXv2RNu2bXVXEgMv1rTEx8frLW6ePn06wsLCMGPGDDRp0gTR0dGIiopCvXr1pDgFIqIyGzlyJFxdXXH79m38/PPPUpeD//73v8jPz0fHjh3RrFkzqcshIioRs7irAwC4uLhg06ZNRb7v4eEBQRAKjM+aNQuzZs0yZGlERAZna2uLt99+G+Hh4fjqq68QFBQkWS2ZmZlYuXIlAM72EpF5MZsZXyIiuZs8eTKsra0RGxuLmJgYyepYt24dnj59ivr166N3796S1UFEVFoMvkREZsLNzQ2jRo0CAHz11VeS1JCfn48lS5YAeLGcjI+AJyJzwj+xiIjMyPTp0wEAP//8M27evGn0z9+9ezcSEhJQpUqVl96OkojI1DD4EhGZkcaNG6Nr167QaDT45ptvjP752pnmSZMmoVKlSkb/fCKi8mDwJSIyM9oLylavXl3ipxWJ4dSpUzh8+DAsLS0REhJitM8lIhILgy8RkZkJDAxEgwYNkJGRgTVr1hjtc7UPrAgKCkKtWrWM9rlERGJh8CUiMjNKpVK31vebb75Bfn6+wT/z7t272LJlCwDewoyIzBeDLxGRGRo9ejReeeUV3Lx5E7t27TL45y1fvhx5eXlo164dWrZsafDPIyIyBAZfIiIzZGdnh8mTJwMw/K3NsrKysGLFCgBAaGioQT+LiMiQGHyJiMzUlClTYG1tjePHjyM2NtZgn/PDDz/gyZMn8PT0RJ8+fQz2OUREhsbgS0RkplQqFYYPHw7g7wvPxKbRaHTHnj59OiwsLAzyOURExsDgS0RkxrQXmm3fvh23b98W/fh79uzBtWvX4OTkhODgYNGPT0RkTAy+RERmrEmTJujcuTPy8/MN8kCLL7/8EsCLB1Y4ODiIfnwiImNi8CUiMnMzZ84EAKxcuRKPHz8W7bgxMTE4dOgQLC0tMXXqVNGOS0QkFQZfIiIz16NHDzRp0gSZmZmizvrOnz8fADBmzBi4u7uLdlwiIqkw+BIRmTmFQoHZs2cDePFACzEeY3zmzBn89ttvUCqVmDVrVrmPR0RkChh8iYgqgIEDB6JBgwZIS0vD//73v3If77PPPgMADBs2DF5eXuU+HhGRKWDwJSKqAJRKJT788EMALy5Iy8rKKvOx/vrrL+zYsQMAdMckIqoIGHyJiCqIYcOGwdPTE48ePcJ3331X5uMsWLAAgiBg4MCBaNiwoYgVEhFJi8GXiKiCsLS0RFhYGADgiy++wPPnz0t9jISEBGzatAkA8J///EfU+oiIpMbgS0RUgYwZMwa1atVCcnIy1q1bV+r9P//8c2g0GvTs2RPNmzcXv0AiIgkx+BIRVSDW1tb44IMPALwIsWq1usT73r59G+vXrwcA3V0iiIgqEgZfIqIKZsKECXBzc8OtW7ewYcOGEu/3xRdfQK1Wo1OnTvD39zdghURE0mDwJSKqYOzs7HRPc5s/f36JZn3v3LmjuyCOs71EVFEx+BIRVUBvv/02qlWrhoSEBN3yheLMnz8fOTk5aN++PTp16mSEComIjI/Bl4ioAqpUqZLuiWvz5s1DTk5OkdsmJiZizZo1um0VCoVRaiQiMjYGXyKiCur//b//hxo1auD27dtYvXp1kdvNmzcParUaXbt2Rfv27Y1YIRGRcTH4EhFVUHZ2drp78c6fPx/Pnj0rsM3Vq1fxww8/AHgRgImIKjIGXyKiCmzChAmoXbs27t27h2+//bbA++Hh4cjPz0evXr3g5+cnQYVERMbD4EtEVIHZ2Njg448/BvDivr6ZmZm69y5duoSffvoJAPDJJ59IUh8RkTGZTfBNTU3FyJEj4ejoCGdnZ0yYMEHvD/DCpKSkYPTo0VCpVKhUqRKaN2+OHTt2GKliIiLTMGbMGNSrVw8PHz7EsmXLdOOffPIJBEHAwIED+ZQ2IpIFswm+I0eOxKVLlxAVFYXdu3fj8OHDmDRpUrH7jBkzBvHx8YiIiMCFCxcwcOBABAUF4cyZM0aqmohIelZWVpg7dy6AFw+pePr0KW7cuIGdO3dCoVAgPDxc2gKJiIzELILv5cuXERkZidWrV8PPzw9t27bFsmXLsHnzZiQnJxe53/HjxzF16lS0bt0anp6emD17NpydnXHq1CkjVk9EJL3hw4ejQYMGePLkCZYuXYpNmzYBAIYNG4ZGjRpJXB0RkXFYSl1AScTExMDZ2RktW7bUjQUEBECpVCI2NhYDBgwodL82bdpgy5Yt6NWrF5ydnbF161Y8f/4cHTt2LPKzcnJy9O53mZ6eDgBQq9Wleua9udOeq5zO2VDYS3Gwj+U3e/ZsjBw5Ep9++ikAQKlU4sMPP2RPy4HfS3Gwj+KRay9Ler5mEXxTUlJQrVo1vTFLS0u4uLggJSWlyP22bt2KoUOH4pVXXoGlpSXs7e2xc+dO1K9fv8h9FixYUOiP/fbt2wd7e/uyn4SZioqKkrqECoO9FAf7WHZ2dnbw8fGBq6srcnJy4OjoiISEBCQkJEhdmtnj91Ic7KN45NbL7OzsEm0nafCdNWsWFi5cWOw2ly9fLvPxP/roI6SlpWH//v2oWrUqdu3ahaCgIBw5cgSNGzcudJ+wsDCEhobqXqenp8Pd3R3dunWDo6NjmWsxN2q1GlFRUejatSusrKykLsessZfiYB/F0bNnT+Tn5yMqKgpdunSBjY2N1CWZNX4vxcE+ikeuvdT+hP5lJA2+M2fOxLhx44rdxtPTEyqVCg8ePNAbz8vLQ2pqKlQqVaH7JSQk4L///S8uXryIhg0bAgCaNGmCI0eOYPny5VixYkWh+9nY2BT6F4GVlZWsvkBacj1vQ2AvxcE+lp/2R4I2NjbspUj4vRQH+ygeufWypOcqafB1dXWFq6vrS7fz9/dHWloaTp06hRYtWgAADhw4AI1GU+QN17VT3kql/vV7FhYW0Gg05ayciIiIiMyNWdzVoUGDBujevTsmTpyIuLg4HDt2DCEhIRg2bBhq1KgBALh79y58fHwQFxcHAPDx8UH9+vXx1ltvIS4uDgkJCfjyyy8RFRWF/v37S3g2RERERCQFswi+ALBx40b4+PigS5cu6NmzJ9q2bYtVq1bp3ler1YiPj9fN9FpZWWHPnj1wdXVFnz598Nprr+GHH37A+vXr0bNnT6lOg4iIiIgkYhZ3dQAAFxcX3X0nC+Ph4QFBEPTGvLy8+KQ2IiIiIgJgRjO+RERERETlweBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREssDgS0RERESywOBLRERERLLA4EtEREREsmA2wXf+/Plo06YN7O3t4ezsXKJ9BEHAxx9/jOrVq8POzg4BAQG4du2aYQslIiIiIpNkNsE3NzcXQ4YMweTJk0u8z6JFi/DNN99gxYoViI2NRaVKlRAYGIjnz58bsFIiIiIiMkWWUhdQUuHh4QCAdevWlWh7QRCwZMkSzJ49G/369QMA/PDDD3Bzc8OuXbswbNgwQ5VKRERERCbIbIJvaSUmJiIlJQUBAQG6MScnJ/j5+SEmJqbI4JuTk4OcnBzd66dPnwIAUlNToVarDVu0CVGr1cjOzsbjx49hZWUldTlmjb0UB/soHvZSPOylONhH8ci1lxkZGQBeTHwWp8IG35SUFACAm5ub3ribm5vuvcIsWLBAN7v8T3Xr1hW3QCIiIiISVUZGBpycnIp8X9LgO2vWLCxcuLDYbS5fvgwfHx8jVQSEhYUhNDRU91qj0SA1NRWvvPIKFAqF0eqQWnp6Otzd3ZGUlARHR0epyzFr7KU42EfxsJfiYS/FwT6KR669FAQBGRkZqFGjRrHbSRp8Z86ciXHjxhW7jaenZ5mOrVKpAAD3799H9erVdeP3799H06ZNi9zPxsYGNjY2emMlvYtEReTo6Cir/3AMib0UB/soHvZSPOylONhH8cixl8XN9GpJGnxdXV3h6upqkGPXrVsXKpUK0dHRuqCbnp6O2NjYUt0ZgoiIiIgqBrO5ndnt27dx9uxZ3L59G/n5+Th79izOnj2LzMxM3TY+Pj7YuXMnAEChUGD69On49NNPERERgQsXLmDMmDGoUaMG+vfvL9FZEBEREZFUzObito8//hjr16/XvW7WrBkA4ODBg+jYsSMAID4+XncXBgB4//33kZWVhUmTJiEtLQ1t27ZFZGQkbG1tjVq7ObKxscGcOXMKLPug0mMvxcE+ioe9FA97KQ72UTzsZfEUwsvu+0BEREREVAGYzVIHIiIiIqLyYPAlIiIiIllg8CUiIiIiWWDwJSIiIiJZYPAlndTUVIwcORKOjo5wdnbGhAkT9G4XV5SYmBh07twZlSpVgqOjI9q3b49nz54ZoWLTVNY+Ai+ePNOjRw8oFArs2rXLsIWagdL2MjU1FVOnToW3tzfs7OxQu3ZtTJs2Te9uL3KxfPlyeHh4wNbWFn5+foiLiyt2+23btsHHxwe2trZo3Lgx9uzZY6RKTV9pevndd9+hXbt2qFKlCqpUqYKAgICX9l4uSvud1Nq8eTMUCgVvRfoPpe1lWloapkyZgurVq8PGxgavvvqqfP8bF4j+T/fu3YUmTZoIJ06cEI4cOSLUr19fGD58eLH7HD9+XHB0dBQWLFggXLx4Ubhy5YqwZcsW4fnz50aq2vSUpY9aX331ldCjRw8BgLBz507DFmoGStvLCxcuCAMHDhQiIiKE69evC9HR0YKXl5cwaNAgI1Ytvc2bNwvW1tbC2rVrhUuXLgkTJ04UnJ2dhfv37xe6/bFjxwQLCwth0aJFwl9//SXMnj1bsLKyEi5cuGDkyk1PaXs5YsQIYfny5cKZM2eEy5cvC+PGjROcnJyEO3fuGLly01LaPmolJiYKNWvWFNq1ayf069fPOMWauNL2MicnR2jZsqXQs2dP4ejRo0JiYqJw6NAh4ezZs0au3DQw+JIgCILw119/CQCEkydP6sZ+//13QaFQCHfv3i1yPz8/P2H27NnGKNEslLWPgiAIZ86cEWrWrCncu3ePwVcoXy//aevWrYK1tbWgVqsNUaZJat26tTBlyhTd6/z8fKFGjRrCggULCt0+KChI6NWrl96Yn5+f8NZbbxm0TnNQ2l7+W15enlC5cmVh/fr1hirRLJSlj3l5eUKbNm2E1atXC2PHjmXw/T+l7eW3334reHp6Crm5ucYq0aRxqQMBeLFcwdnZGS1bttSNBQQEQKlUIjY2ttB9Hjx4gNjYWFSrVg1t2rSBm5sbOnTogKNHjxqrbJNTlj4CQHZ2NkaMGIHly5dDpVIZo1STV9Ze/tvTp0/h6OgIS0uzeV5PueTm5uLUqVMICAjQjSmVSgQEBCAmJqbQfWJiYvS2B4DAwMAit5eLsvTy37Kzs6FWq+Hi4mKoMk1eWfv4ySefoFq1apgwYYIxyjQLZellREQE/P39MWXKFLi5uaFRo0b47LPPkJ+fb6yyTQqDLwEAUlJSUK1aNb0xS0tLuLi4ICUlpdB9bty4AQCYO3cuJk6ciMjISDRv3hxdunTBtWvXDF6zKSpLHwFgxowZaNOmDfr162foEs1GWXv5T48ePcK8efMwadIkQ5Rokh49eoT8/Hy4ubnpjbu5uRXZt5SUlFJtLxdl6eW/ffDBB6hRo0aBf1jISVn6ePToUaxZswbfffedMUo0G2Xp5Y0bN7B9+3bk5+djz549+Oijj/Dll1/i008/NUbJJofBt4KbNWsWFApFsb+uXLlSpmNrNBoAwFtvvYXg4GA0a9YMX3/9Nby9vbF27VoxT0NyhuxjREQEDhw4gCVLlohbtIkyZC//KT09Hb169YKvry/mzp1b/sKJSunzzz/H5s2bsXPnTtja2kpdjtnIyMjA6NGj8d1336Fq1apSl2P2NBoNqlWrhlWrVqFFixYYOnQo/vOf/2DFihVSlyYJefzsT8ZmzpyJcePGFbuNp6cnVCoVHjx4oDeel5eH1NTUIn/0Xr16dQCAr6+v3niDBg1w+/btshdtggzZxwMHDiAhIQHOzs5644MGDUK7du1w6NChclRuegzZS62MjAx0794dlStXxs6dO2FlZVXess1G1apVYWFhgfv37+uN379/v8i+qVSqUm0vF2XppdbixYvx+eefY//+/XjttdcMWabJK20fExIScPPmTfTp00c3pp1osbS0RHx8POrVq2fYok1UWb6T1atXh5WVFSwsLHRjDRo0QEpKCnJzc2FtbW3Qmk0Ng28F5+rqCldX15du5+/vj7S0NJw6dQotWrQA8CKQaTQa+Pn5FbqPh4cHatSogfj4eL3xq1evokePHuUv3oQYso+zZs3Cm2++qTfWuHFjfP3113p/8FcUhuwl8GKmNzAwEDY2NoiIiJDdTJu1tTVatGiB6Oho3e2fNBoNoqOjERISUug+/v7+iI6OxvTp03VjUVFR8Pf3N0LFpqssvQSARYsWYf78+di7d6/eGnW5Km0ffXx8cOHCBb2x2bNnIyMjA0uXLoW7u7sxyjZJZflOvvHGG9i0aRM0Gg2Uyhc/6L969SqqV68uu9ALgLczo791795daNasmRAbGyscPXpU8PLy0rt11J07dwRvb28hNjZWN/b1118Ljo6OwrZt24Rr164Js2fPFmxtbYXr169LcQomoSx9/Dfwrg6CIJS+l0+fPhX8/PyExo0bC9evXxfu3bun+5WXlyfVaRjd5s2bBRsbG2HdunXCX3/9JUyaNElwdnYWUlJSBEEQhNGjRwuzZs3SbX/s2DHB0tJSWLx4sXD58mVhzpw5vJ3Z/yltLz///HPB2tpa2L59u973LyMjQ6pTMAml7eO/8a4OfyttL2/fvi1UrlxZCAkJEeLj44Xdu3cL1apVEz799FOpTkFSDL6k8/jxY2H48OGCg4OD4OjoKAQHB+v9YZ2YmCgAEA4ePKi334IFC4RatWoJ9vb2gr+/v3DkyBEjV25aytrHf2LwfaG0vTx48KAAoNBfiYmJ0pyERJYtWybUrl1bsLa2Flq3bi2cOHFC916HDh2EsWPH6m2/detW4dVXXxWsra2Fhg0bCr/99puRKzZdpellnTp1Cv3+zZkzx/iFm5jSfif/icFXX2l7efz4ccHPz0+wsbERPD09hfnz58tqMuCfFIIgCMafZyYiIiIiMi7e1YGIiIiIZIHBl4iIiIhkgcGXiIiIiGSBwZeIiIiIZIHBl4iIiIhkgcGXiIiIiGSBwZeIiIiIZIHBl4iIiIhkgcGXiIiIiGSBwZeIiIiIZIHBl4iIiIhkgcGXiEgGHj58CJVKhc8++0w3dvz4cVhbWyM6OlrCyoiIjEchCIIgdRFERGR4e/bsQf/+/XH8+HF4e3ujadOm6NevH7766iupSyMiMgoGXyIiGZkyZQr279+Pli1b4sKFCzh58iRsbGykLouIyCgYfImIZOTZs2do1KgRkpKScOrUKTRu3FjqkoiIjIZrfImIZCQhIQHJycnQaDS4efOm1OUQERkVZ3yJiGQiNzcXrVu3RtOmTeHt7Y0lS5bgwoULqFatmtSlEREZBYMvEZFMvPfee9i+fTvOnTsHBwcHdOjQAU5OTti9e7fUpRERGQWXOhARycChQ4ewZMkS/Pjjj3B0dIRSqcSPP/6II0eO4Ntvv5W6PCIio+CMLxERERHJAmd8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBYYfImIiIhIFhh8iYiIiEgWGHyJiIiISBb+PwgRDYz4GDKxAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Testing: Draw a boat and rotate it 45 degrees\n", + "boat = Shape2D(np.array([[0, 2], [-1, 1], [-1, -2], [1, -2], [1, 1], [0, 2]]))\n", + "boat.rotate(45, np.array([0, 0])).plot()\n", + "plt.xlim(-3, 3)\n", + "plt.ylim(-3, 3)\n", + "plt.grid(True)\n", + "\n", + "# Testing: Plot the airfoil\n", + "airfoil_points = get_symmetric_air_foil_points()\n", + "x_Coordinates = airfoil_points[:, 0]\n", + "y_Coordinates = airfoil_points[:, 1]\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.plot(x_Coordinates, y_Coordinates, label=\"Airfoil\", color=\"black\")\n", + "plt.xlabel(\"x\")\n", + "plt.ylabel(\"y\")\n", + "plt.xlim(-0.75, 0.75)\n", + "plt.ylim(-1.00, 0.50)\n", + "plt.title(\"Symmetric Airfoil Shape\")\n", + "plt.grid(True)\n", + "plt.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Text(0.5, 1.0, 'Boat with Main Sail and Trim Tab Example')" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Define basic shapes\n", + "\n", + "# Boat shape\n", + "boat = Shape2D(np.array([[0, 2], [-1, 1], [-1, -2], [1, -2], [1, 1], [0, 2]]))\n", + "\n", + "# Main sail and trim tab shapes\n", + "airfoil_points = get_symmetric_air_foil_points()\n", + "main_sail_points = np.vstack((airfoil_points, np.array([[0, -0.8], [0, -1.5]])))\n", + "trim_tab_points = airfoil_points\n", + "\n", + "main_sail = Shape2D(main_sail_points).scale(2.5).translate([0, 0.75])\n", + "trim_tab = Shape2D(trim_tab_points)\n", + "\n", + "# Testing: Plot the boat, main sail, and trim tab\n", + "Shape2D.plot_shapes(None, boat, main_sail, trim_tab.translate([0, -3]))\n", + "plt.xlim(-3, 3)\n", + "plt.ylim(-4, 4)\n", + "plt.grid(True)\n", + "plt.title(\"Boat with Main Sail and Trim Tab Example\")" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import os\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.gridspec import GridSpec\n", + "from IPython.display import display, clear_output\n", + "import time\n", + "\n", + "\n", + "def create_figure():\n", + " # Create a 2x3 grid specification\n", + " gs = GridSpec(2, 3, width_ratios=[1, 1, 1])\n", + "\n", + " # Create the main plot (2x2 grid)\n", + " ax_boat = plt.subplot(gs[:, :2])\n", + " ax_boat.set_title(\"Wingsail Controller Visualization\")\n", + " ax_boat.set_xlim(-7, 7)\n", + " ax_boat.set_ylim(-7, 7)\n", + " ax_boat.axhline(0, color=\"black\", linewidth=0.5)\n", + " ax_boat.axvline(0, color=\"black\", linewidth=0.5)\n", + " ax_boat.set_xticks([])\n", + " ax_boat.set_yticks([])\n", + "\n", + " # Create the remaining 1x1 plots\n", + " ax_data = plt.subplot(gs[0, 2])\n", + " ax_data.set_title(\"Visualization Data\", fontsize=10)\n", + " ax_data.set_xlim(-1, 1)\n", + " ax_data.set_ylim(-1, 1)\n", + " ax_data.set_xticks([])\n", + " ax_data.set_yticks([])\n", + "\n", + " ax_angle = plt.subplot(gs[1, 2])\n", + " ax_angle.set_title(\"Angle between Wind and Sail\", fontsize=10)\n", + " ax_angle.axhline(0, color=\"black\", linewidth=0.5)\n", + " ax_angle.axvline(0, color=\"black\", linewidth=0.5)\n", + " ax_angle.set_xlim(-2, 2)\n", + " ax_angle.set_ylim(-2, 2)\n", + " ax_angle.set_xticks([])\n", + " ax_angle.set_yticks([])\n", + "\n", + " # Adjust spacing between subplots\n", + " plt.tight_layout()\n", + "\n", + " return ax_boat, ax_data, ax_angle\n", + "\n", + "\n", + "def create_video(save_frames=True):\n", + " if save_frames:\n", + " dir_name = os.path.join(os.getcwd(), \"frames\")\n", + " os.makedirs(dir_name, exist_ok=True)\n", + " else:\n", + " dir_name = None\n", + "\n", + " # Create simply list of wind directions that increases in linear fashion\n", + " simple_wind_direction = list(range(-80, -30, 1)) + list(range(-30, -80, -1))\n", + " simple_wind_speed = len(simple_wind_direction) * [6]\n", + " simple_wind_speed += list(range(6, 28, 1))\n", + " simple_wind_direction += len(range(6, 28, 1)) * [simple_wind_direction[-1]]\n", + "\n", + " # Create randomized wind directions (very fun!)\n", + " random_wind_direction = randomizeWindDirection(7)\n", + "\n", + " # Create data\n", + " apparent_wind_directions = simple_wind_direction\n", + " apparent_wind_speeds = simple_wind_speed\n", + "\n", + " i = 0\n", + "\n", + " # Display the animation\n", + " for apparent_wind_direction, apparent_wind_speed in zip(\n", + " apparent_wind_directions, apparent_wind_speeds\n", + " ):\n", + "\n", + " # Determine trimtab angle\n", + " reynolds = compute_reynolds_number(apparent_wind_speed)\n", + " a = compute_angle_of_attack(reynolds, look_up_table)\n", + " t = compute_trim_tab_angle(a, apparent_wind_direction)\n", + "\n", + " rotated_wingsail = main_sail.rotate(apparent_wind_direction - t, np.array([0, 0.75]))\n", + " rotated_trimtab = (\n", + " trim_tab.translate([0, -3])\n", + " .rotate(apparent_wind_direction - t, np.array([0, 0.75]))\n", + " .rotate(t, rotated_wingsail.points[-1, :])\n", + " )\n", + "\n", + " # Drawing commands\n", + " ax_boat, ax_data, ax_angle = create_figure()\n", + " drawWindField(apparent_wind_direction, ax_boat)\n", + " Shape2D.plot_shapes(ax_boat, boat, rotated_wingsail, rotated_trimtab)\n", + " drawValues(apparent_wind_speed, apparent_wind_direction, a, t, ax_data)\n", + " drawVectors(apparent_wind_direction, apparent_wind_direction - t, ax_angle)\n", + "\n", + " ax_boat.legend(loc=\"upper left\")\n", + " ax_angle.legend(loc=\"upper left\")\n", + "\n", + " if save_frames:\n", + " plt.savefig(os.path.join(dir_name, f\"frame_{i:03d}.png\"))\n", + " plt.clf()\n", + " i += 1\n", + " else:\n", + " plt.show()\n", + " clear_output(wait=True)\n", + " time.sleep(0.01)\n", + "\n", + " return dir_name" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'/workspaces/sailbot_workspace/src/notebooks/controller/frames'" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Set the parameter to false to not save the frames and render the visualization in the notebook\n", + "create_video(save_frames=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ffmpeg version 4.4.2-0ubuntu0.22.04.1 Copyright (c) 2000-2021 the FFmpeg developers\n", + " built with gcc 11 (Ubuntu 11.2.0-19ubuntu1)\n", + " configuration: --prefix=/usr --extra-version=0ubuntu0.22.04.1 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librabbitmq --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opencl --enable-opengl --enable-sdl2 --enable-pocketsphinx --enable-librsvg --enable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared\n", + " libavutil 56. 70.100 / 56. 70.100\n", + " libavcodec 58.134.100 / 58.134.100\n", + " libavformat 58. 76.100 / 58. 76.100\n", + " libavdevice 58. 13.100 / 58. 13.100\n", + " libavfilter 7.110.100 / 7.110.100\n", + " libswscale 5. 9.100 / 5. 9.100\n", + " libswresample 3. 9.100 / 3. 9.100\n", + " libpostproc 55. 9.100 / 55. 9.100\n", + "Input #0, image2, from 'frames/frame_*.png':\n", + " Duration: 00:00:12.20, start: 0.000000, bitrate: N/A\n", + " Stream #0:0: Video: png, rgba(pc), 640x480 [SAR 3937:3937 DAR 4:3], 10 fps, 10 tbr, 10 tbn, 10 tbc\n", + "Stream mapping:\n", + " Stream #0:0 -> #0:0 (png (native) -> h264 (libx264))\n", + "Press [q] to stop, [?] for help\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0musing SAR=1/1\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0musing cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mprofile High, level 2.2, 4:2:0, 8-bit\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0m264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=15 lookahead_threads=2 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=10 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00\n", + "Output #0, mp4, to 'frames/wingsail_controller_demo.mp4':\n", + " Metadata:\n", + " encoder : Lavf58.76.100\n", + " Stream #0:0: Video: h264 (avc1 / 0x31637661), yuv420p(tv, progressive), 640x480 [SAR 1:1 DAR 4:3], q=2-31, 10 fps, 10240 tbn\n", + " Metadata:\n", + " encoder : Lavc58.134.100 libx264\n", + " Side data:\n", + " cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A\n", + "frame= 122 fps=0.0 q=-1.0 Lsize= 300kB time=00:00:11.90 bitrate= 206.7kbits/s speed= 18x \n", + "video:298kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.758996%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mframe I:1 Avg QP:16.04 size: 24434\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mframe P:33 Avg QP:17.55 size: 6395\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mframe B:88 Avg QP:22.75 size: 784\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mconsecutive B-frames: 2.5% 3.3% 2.5% 91.8%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mmb I I16..4: 40.8% 9.8% 49.4%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mmb P I16..4: 2.5% 1.4% 0.2% P16..4: 15.5% 10.7% 6.3% 0.0% 0.0% skip:63.4%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mmb B I16..4: 1.0% 1.2% 0.0% B16..8: 12.4% 1.6% 0.6% direct: 2.0% skip:81.2% L0:45.1% L1:36.8% BI:18.0%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0m8x8 transform intra:37.9% inter:4.5%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mcoded y,uvDC,uvAC intra: 8.5% 0.3% 0.3% inter: 4.1% 0.2% 0.2%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mi16 v,h,dc,p: 45% 47% 8% 0%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mi8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 3% 5% 91% 0% 0% 0% 0% 0% 0%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mi4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 50% 28% 11% 1% 1% 1% 2% 1% 5%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mi8c dc,h,v,p: 100% 0% 0% 0%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mWeighted P-Frames: Y:0.0% UV:0.0%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mref P L0: 78.3% 2.6% 12.8% 6.3%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mref B L0: 71.3% 20.8% 7.9%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mref B L1: 92.0% 8.0%\n", + "\u001b[1;36m[libx264 @ 0x55b164dab940] \u001b[0mkb/s:199.64\n" + ] + } + ], + "source": [ + "!ffmpeg -framerate 10 -pattern_type glob -i 'frames/frame_*.png' -c:v libx264 -pix_fmt yuv420p -y frames/wingsail_controller_demo.mp4" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Future Considerations\n", + "\n", + "1. If the wind direction is in a range where the boat is in irons (wind direction is in the no go zone), the angle of\n", + "attack should be set to zero. " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/local_pathfinding/coord_systems.ipynb b/notebooks/local_pathfinding/coord_systems.ipynb new file mode 100644 index 000000000..05ec34b95 --- /dev/null +++ b/notebooks/local_pathfinding/coord_systems.ipynb @@ -0,0 +1,1911 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Coordinate Systems Research" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defaulting to user installation because normal site-packages is not writeable\n", + "Requirement already satisfied: geopy in /home/ros/.local/lib/python3.10/site-packages (2.4.0)\n", + "Requirement already satisfied: nbformat in /home/ros/.local/lib/python3.10/site-packages (5.9.2)\n", + "Requirement already satisfied: pandas in /home/ros/.local/lib/python3.10/site-packages (2.1.1)\n", + "Requirement already satisfied: pyproj in /home/ros/.local/lib/python3.10/site-packages (3.6.1)\n", + "Requirement already satisfied: geographiclib<3,>=1.52 in /home/ros/.local/lib/python3.10/site-packages (from geopy) (2.0)\n", + "Requirement already satisfied: fastjsonschema in /home/ros/.local/lib/python3.10/site-packages (from nbformat) (2.18.0)\n", + "Requirement already satisfied: jsonschema>=2.6 in /home/ros/.local/lib/python3.10/site-packages (from nbformat) (4.19.1)\n", + "Requirement already satisfied: jupyter-core in /home/ros/.local/lib/python3.10/site-packages (from nbformat) (5.3.1)\n", + "Requirement already satisfied: traitlets>=5.1 in /home/ros/.local/lib/python3.10/site-packages (from nbformat) (5.10.0)\n", + "Requirement already satisfied: pytz>=2020.1 in /home/ros/.local/lib/python3.10/site-packages (from pandas) (2023.3.post1)\n", + "Requirement already satisfied: tzdata>=2022.1 in /home/ros/.local/lib/python3.10/site-packages (from pandas) (2023.3)\n", + "Requirement already satisfied: numpy>=1.22.4 in /home/ros/.local/lib/python3.10/site-packages (from pandas) (1.26.0)\n", + "Requirement already satisfied: python-dateutil>=2.8.2 in /home/ros/.local/lib/python3.10/site-packages (from pandas) (2.8.2)\n", + "Requirement already satisfied: certifi in /home/ros/.local/lib/python3.10/site-packages (from pyproj) (2023.7.22)\n", + "Requirement already satisfied: attrs>=22.2.0 in /home/ros/.local/lib/python3.10/site-packages (from jsonschema>=2.6->nbformat) (23.1.0)\n", + "Requirement already satisfied: rpds-py>=0.7.1 in /home/ros/.local/lib/python3.10/site-packages (from jsonschema>=2.6->nbformat) (0.10.3)\n", + "Requirement already satisfied: referencing>=0.28.4 in /home/ros/.local/lib/python3.10/site-packages (from jsonschema>=2.6->nbformat) (0.30.2)\n", + "Requirement already satisfied: jsonschema-specifications>=2023.03.6 in /home/ros/.local/lib/python3.10/site-packages (from jsonschema>=2.6->nbformat) (2023.7.1)\n", + "Requirement already satisfied: six>=1.5 in /usr/lib/python3/dist-packages (from python-dateutil>=2.8.2->pandas) (1.16.0)\n", + "Requirement already satisfied: platformdirs>=2.5 in /usr/lib/python3/dist-packages (from jupyter-core->nbformat) (2.5.1)\n" + ] + } + ], + "source": [ + "!pip3 install geopy nbformat pandas pyproj" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import math\n", + "import timeit\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "from geopy import distance\n", + "from pyproj import Geod, Proj\n", + "from typing import NamedTuple, Tuple" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "GEODESIC = Geod(ellps=\"WGS84\")\n", + "EARTH_RADIUS_KM = 6378.137" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "class LatLon(NamedTuple):\n", + " latitude: float\n", + " longitude: float\n", + "\n", + "\n", + "class XY(NamedTuple):\n", + " x: float\n", + " y: float\n", + "\n", + "\n", + "def cartesian_to_true_bearing(cartesian: float):\n", + " \"\"\"Convert a cartesian angle (0 is east, ccw) to true bearing (0 is north, cw).\n", + "\n", + " Examples\n", + " - 0 -> 90\n", + " - 90 -> 0\n", + " - 180 -> 270\n", + " - 270 -> 180\n", + " \"\"\"\n", + " return (90 - cartesian + 360) % 360" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Speed of Geographical Distance Implementations\n", + "\n", + "Geographical distance is the distance measured along the surface of the earth.\n", + "In Raye we calculate it using the [geopy](https://geopy.readthedocs.io/en/stable/index.html#)\n", + "library. The purpose of this experiment is to find the fastest geographical distance implementation.\n", + "\n", + "Implementations that we will be comparing:\n", + "\n", + "- [`geopy.distance.distance`](https://geopy.readthedocs.io/en/stable/index.html#module-geopy.distance)\n", + "- [`pyproj.Geod.inv`](https://pyproj4.github.io/pyproj/stable/api/geod.html#pyproj.Geod.inv)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create wrappers around the implementations so that they can be easily called:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "start = LatLon(49.128211, -123.191820)\n", + "dest = LatLon(49.148232, -123.158331)\n", + "\n", + "\n", + "def get_dist_geopy():\n", + " return distance.distance(start, dest).kilometers\n", + "\n", + "\n", + "def get_dist_pyproj():\n", + " return GEODESIC.inv(start.longitude, start.latitude, dest.longitude, dest.latitude)[2] / 1000" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Check that implementations return the same value:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "assert math.isclose(get_dist_geopy(), get_dist_pyproj())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Get the average runtime:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'geopy_time=1.383742s, pyproj_time=0.010706s'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "geopy_time = timeit.timeit(get_dist_geopy, number=10000)\n", + "pyproj_time = timeit.timeit(get_dist_pyproj, number=10000)\n", + "\n", + "f\"{geopy_time=:.6f}s, {pyproj_time=:.6f}s\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Conclusion: the pyproj implementation is much faster than geopy. This makes sense, as pyproj is an interface to the C++ library [PROJ](https://proj.org/en/9.2/), whereas geopy is purely in Python." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'pyproj is 129.25x faster than geopy'" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "f\"pyproj is {geopy_time / pyproj_time:.2f}x faster than geopy\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Accuracy of Projection Methods\n", + "\n", + "To simplify things, our pathfinding algorithms operate on a 2D grid.\n", + "However, the position data that we receive is geographical coordinates on the earth, an ellipsoid.\n", + "Thus, to solve for a path, we project a section of the earth encompassing the current position and next global waypoint\n", + "onto a 2D plane.\n", + "\n", + "There are a number of projection methods that are commonly used. The purpose of this experiment is to\n", + "find the best projection method in terms of distance and course accuracy." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Projection methods that we will be comparing:\n", + "\n", + "- `appr`: [Approximate lookup](https://stackoverflow.com/a/1253545)\n", + "- `equi`: [Equirectangular projection](https://stackoverflow.com/a/16271669)\n", + "- `raye`: [Raye](https://github.com/UBCSailbot/raye-local-pathfinding/blob/7e1d4ce903ac78a41580c76566e84313d11185ba/python/utilities.py#L117-L133)\n", + "- [Pyproj projections](https://proj.org/en/9.2/operations/projections/index.html)\n", + "- `trap`: Jamen's trapezoid-based method\n", + "- `geod`: Using pyproj's `inv` function which return the distance and course given 2 coordinates" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "def latlon2xy(\n", + " proj: str,\n", + " reference: LatLon,\n", + " latlon: LatLon,\n", + " aspect_ratio: float = None,\n", + " geodesic: Geod = None,\n", + " p: Proj = None,\n", + ") -> XY:\n", + " if proj == \"appr\":\n", + " xy = appr(reference, latlon, aspect_ratio)\n", + " elif proj == \"equi\":\n", + " xy = equi(reference, latlon, aspect_ratio)\n", + " elif proj == \"raye\":\n", + " xy = latlon_to_xy(latlon=latlon, reference=reference, geodesic=geodesic)\n", + " elif proj == \"trap\":\n", + " xy = trap(reference, latlon)\n", + " elif proj == \"geod\":\n", + " xy = geod(reference, latlon, geodesic)\n", + " else:\n", + " xy = pyproj(proj, reference, latlon, p)\n", + " return XY(*xy)\n", + "\n", + "\n", + "def appr(reference: LatLon, latlon: LatLon, aspect_ratio: float) -> Tuple[float, float]:\n", + " if aspect_ratio is None:\n", + " aspect_ratio = math.cos(math.radians(np.average((reference.latitude, latlon.latitude))))\n", + " lat_dif = latlon.latitude - reference.latitude\n", + " lon_dif = latlon.longitude - reference.longitude\n", + " lat2km = 110.574\n", + " lon2km = 111.320 * aspect_ratio\n", + " x = lon2km * lon_dif\n", + " y = lat2km * lat_dif\n", + " return x, y\n", + "\n", + "\n", + "def equi(reference: LatLon, latlon: LatLon, aspect_ratio: float) -> Tuple[float, float]:\n", + " if aspect_ratio is None:\n", + " aspect_ratio = math.cos(math.radians(np.average((reference.latitude, latlon.latitude))))\n", + " lat_dif = latlon.latitude - reference.latitude\n", + " lon_dif = latlon.longitude - reference.longitude\n", + " x = EARTH_RADIUS_KM * math.radians(lon_dif) * aspect_ratio\n", + " y = EARTH_RADIUS_KM * math.radians(lat_dif)\n", + " return x, y\n", + "\n", + "\n", + "def latlon_to_xy(latlon: LatLon, reference: LatLon, geodesic: Geod) -> Tuple[float, float]:\n", + " def get_dist_n_course(start: LatLon, dest: LatLon, geodesic: Geod) -> Tuple[float, float]:\n", + " if geodesic is None:\n", + " geodesic = Geod(ellps=\"WGS84\")\n", + " fwd_azimuth, _, distance = geodesic.inv(\n", + " lons1=start.longitude, lats1=start.latitude, lons2=dest.longitude, lats2=dest.latitude\n", + " )\n", + " return distance / 1000, fwd_azimuth % 360\n", + "\n", + " # raye method of converting to 2D cartesian\n", + " x, _ = get_dist_n_course(reference, LatLon(reference.latitude, latlon.longitude), geodesic)\n", + " y, _ = get_dist_n_course(reference, LatLon(latlon.latitude, reference.longitude), geodesic)\n", + " if reference.longitude > latlon.longitude:\n", + " x = -x\n", + " if reference.latitude > latlon.latitude:\n", + " y = -y\n", + " return x, y\n", + "\n", + "\n", + "def trap(reference: LatLon, latlon: LatLon) -> Tuple[float, float]:\n", + " km_per_degree = 2 * math.pi * EARTH_RADIUS_KM / 360.0\n", + "\n", + " w_side_len = km_per_degree * (latlon.latitude - reference.latitude)\n", + " n_side_len = (\n", + " km_per_degree\n", + " * math.cos(math.radians(max(latlon.latitude, reference.latitude)))\n", + " * (latlon.longitude - reference.longitude)\n", + " )\n", + " s_side_len = (\n", + " km_per_degree\n", + " * math.cos(math.radians(min(latlon.latitude, reference.latitude)))\n", + " * (latlon.longitude - reference.longitude)\n", + " )\n", + "\n", + " w_side_plus_minus = 1 if (w_side_len > 0) else (-1)\n", + "\n", + " distance = math.sqrt((n_side_len**2 + s_side_len**2) / 2 + w_side_len**2)\n", + " theta = math.atan2(\n", + " w_side_plus_minus * math.sqrt(w_side_len**2 + ((s_side_len - n_side_len) / 2) ** 2),\n", + " (n_side_len + s_side_len) / 2,\n", + " )\n", + "\n", + " return (distance * math.cos(theta)), (distance * math.sin(theta))\n", + "\n", + "\n", + "def geod(reference: LatLon, latlon: LatLon, geodesic: Geod) -> Tuple[float, float]:\n", + " if geodesic is None:\n", + " geodesic = Geod(ellps=\"WGS84\")\n", + " m_to_km = 0.001\n", + "\n", + " azi12, _, distance = geodesic.inv(\n", + " reference.longitude, reference.latitude, latlon.longitude, latlon.latitude\n", + " )\n", + " distance = distance * m_to_km\n", + "\n", + " return distance * math.sin(math.radians(azi12)), distance * math.cos(math.radians(azi12))\n", + "\n", + "\n", + "def pyproj(proj: str, reference: LatLon, latlon: LatLon, p: Proj) -> Tuple[float, float]:\n", + " if p is None:\n", + " p = Proj(\n", + " proj=proj,\n", + " lat_0=reference.latitude,\n", + " lon_0=reference.longitude,\n", + " ellps=\"WGS84\",\n", + " units=\"km\",\n", + " )\n", + " start_xy = p(reference.longitude, reference.latitude)\n", + " dest_xy = p(latlon.longitude, latlon.latitude)\n", + " return np.subtract(dest_xy, start_xy)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Error functions:\n", + "- `error` is the typical percentage error equation, but in cases where it would divide by zero\n", + " it returns the absolute difference\n", + "- `degree_error` returns the percentage error for circular values with a range of 360" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "def error(expected, observed):\n", + " diff = abs(expected - observed)\n", + " if expected == 0:\n", + " return diff\n", + " return diff / expected * 100\n", + "\n", + "\n", + "def degree_error(expected, observed):\n", + " circular_diff = min(abs(expected - observed), 360 - abs(expected - observed))\n", + " error = circular_diff / 360 * 100\n", + " return error" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Use each projection method in a variety of different cases, varying:\n", + "- Starting latitude\n", + " - We don't expect to be going above 60 degrees\n", + "- Course: direction to destination\n", + "- Distance: distance to destination\n", + " - We don't expect local pathfinding to navigate to waypoints greater than 20km away\n", + "\n", + "For each method, record:\n", + "- Course error\n", + "- Distance error\n", + "- Time: execution time\n", + "- Cached time: execution time when the most expensive part of the method is precomputed and passed in as an argument" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
start_latcourse_degdist_kmmethodcourse (deg)course_err (%)dist (km)dist_err (%)time (s)cached time (s)
0001appr0.00.00.9999982.494530e-040.0005310.000088
1001equi0.00.01.0067396.739497e-010.0005390.000104
2001raye0.00.01.0000002.220446e-140.0031790.000640
3001trap0.00.01.0067396.739497e-010.0002370.000237
4001geod0.00.01.0000002.220446e-140.0004660.000175
5002appr0.00.01.9999952.494780e-040.0005010.000103
6002equi0.00.02.0134796.739496e-010.0005080.000097
7002raye0.00.02.0000002.220446e-140.0009590.000445
8002trap0.00.02.0134796.739496e-010.0002170.000217
9002geod0.00.02.0000002.220446e-140.0004550.000172
10005appr0.00.04.9999882.496531e-040.0005320.000086
11005equi0.00.05.0336976.739495e-010.0005360.000100
\n", + "
" + ], + "text/plain": [ + " start_lat course_deg dist_km method course (deg) course_err (%) \\\n", + "0 0 0 1 appr 0.0 0.0 \n", + "1 0 0 1 equi 0.0 0.0 \n", + "2 0 0 1 raye 0.0 0.0 \n", + "3 0 0 1 trap 0.0 0.0 \n", + "4 0 0 1 geod 0.0 0.0 \n", + "5 0 0 2 appr 0.0 0.0 \n", + "6 0 0 2 equi 0.0 0.0 \n", + "7 0 0 2 raye 0.0 0.0 \n", + "8 0 0 2 trap 0.0 0.0 \n", + "9 0 0 2 geod 0.0 0.0 \n", + "10 0 0 5 appr 0.0 0.0 \n", + "11 0 0 5 equi 0.0 0.0 \n", + "\n", + " dist (km) dist_err (%) time (s) cached time (s) \n", + "0 0.999998 2.494530e-04 0.000531 0.000088 \n", + "1 1.006739 6.739497e-01 0.000539 0.000104 \n", + "2 1.000000 2.220446e-14 0.003179 0.000640 \n", + "3 1.006739 6.739497e-01 0.000237 0.000237 \n", + "4 1.000000 2.220446e-14 0.000466 0.000175 \n", + "5 1.999995 2.494780e-04 0.000501 0.000103 \n", + "6 2.013479 6.739496e-01 0.000508 0.000097 \n", + "7 2.000000 2.220446e-14 0.000959 0.000445 \n", + "8 2.013479 6.739496e-01 0.000217 0.000217 \n", + "9 2.000000 2.220446e-14 0.000455 0.000172 \n", + "10 4.999988 2.496531e-04 0.000532 0.000086 \n", + "11 5.033697 6.739495e-01 0.000536 0.000100 " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = []\n", + "\n", + "dist_wins = np.zeros(4)\n", + "course_wins = np.zeros(4)\n", + "\n", + "start_lon = -123\n", + "for start_lat in [0, 15, 30, 45, 60]:\n", + " start = LatLon(latitude=start_lat, longitude=start_lon)\n", + " for course_deg in [0, 45, 90, 210]:\n", + " for dist_km in [1, 2, 5, 10, 20]:\n", + " dest_lon, dest_lat, _ = GEODESIC.fwd(\n", + " lons=start_lon, lats=start_lat, az=course_deg, dist=dist_km * 1000\n", + " )\n", + " dest = LatLon(latitude=dest_lat, longitude=dest_lon)\n", + "\n", + " for method in [\"appr\", \"equi\", \"raye\", \"trap\", \"geod\"]:\n", + " x, y = latlon2xy(proj=method, reference=start, latlon=dest)\n", + " course_proj = cartesian_to_true_bearing(np.rad2deg(np.arctan2(y, x)))\n", + " course_err = degree_error(expected=course_deg, observed=course_proj)\n", + " dist_proj = np.hypot(x, y)\n", + " dist_err = error(expected=dist_km, observed=dist_proj)\n", + " time_proj = timeit.timeit(\n", + " lambda: latlon2xy(proj=method, reference=start, latlon=dest),\n", + " number=100,\n", + " )\n", + " if method in [\"appr\", \"equi\"]:\n", + " aspect_ratio = np.cos(np.deg2rad(np.average((start.latitude, dest.latitude))))\n", + " time_proj_cached = timeit.timeit(\n", + " lambda: latlon2xy(\n", + " proj=method, reference=start, latlon=dest, aspect_ratio=aspect_ratio\n", + " ),\n", + " number=100,\n", + " )\n", + " elif method in [\"raye\", \"geod\"]:\n", + " time_proj_cached = timeit.timeit(\n", + " lambda: latlon2xy(\n", + " proj=method, reference=start, latlon=dest, geodesic=GEODESIC\n", + " ),\n", + " number=100,\n", + " )\n", + " elif method == \"trap\":\n", + " time_proj_cached = time_proj # not cached\n", + " else:\n", + " p = Proj(\n", + " proj=method,\n", + " lat_0=start.latitude,\n", + " lon_0=start.longitude,\n", + " ellps=\"WGS84\",\n", + " units=\"km\",\n", + " )\n", + " time_proj_cached = timeit.timeit(\n", + " lambda: latlon2xy(proj=method, reference=start, latlon=dest, p=p),\n", + " number=100,\n", + " )\n", + "\n", + " df.append(\n", + " {\n", + " \"start_lat\": start_lat,\n", + " \"course_deg\": course_deg,\n", + " \"dist_km\": dist_km,\n", + " \"method\": method,\n", + " \"course (deg)\": course_proj,\n", + " \"course_err (%)\": course_err,\n", + " \"dist (km)\": dist_proj,\n", + " \"dist_err (%)\": dist_err,\n", + " \"time (s)\": time_proj,\n", + " \"cached time (s)\": time_proj_cached,\n", + " },\n", + " ),\n", + "\n", + "df = pd.DataFrame(df)\n", + "df.head(12)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Cases with the worst course error" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
start_latcourse_degdist_kmmethodcourse (deg)course_err (%)dist (km)dist_err (%)time (s)cached time (s)
260451equi44.8075770.0534511.0033750.3375410.0004820.000093
280451trap44.8075770.0534511.0033750.3375410.0002110.000211
310452equi44.8075770.0534512.0067510.3375410.0004790.000094
330452trap44.8075770.0534512.0067510.3375400.0002120.000212
360455equi44.8075800.0534505.0168770.3375410.0004840.000093
380455trap44.8075790.0534505.0168770.3375390.0002120.000212
4104510equi44.8075900.05344710.0337540.3375430.0005470.000100
4304510trap44.8075860.05344810.0337540.3375350.0002250.000225
4604520equi44.8076300.05343620.0675100.3375490.0005010.000096
4804520trap44.8076120.05344120.0675040.3375180.0002190.000219
440604510appr45.1994720.0554099.9498850.5011480.0004990.000092
445604520appr45.2547650.07076819.8998230.5008830.0004800.000083
\n", + "
" + ], + "text/plain": [ + " start_lat course_deg dist_km method course (deg) course_err (%) \\\n", + "26 0 45 1 equi 44.807577 0.053451 \n", + "28 0 45 1 trap 44.807577 0.053451 \n", + "31 0 45 2 equi 44.807577 0.053451 \n", + "33 0 45 2 trap 44.807577 0.053451 \n", + "36 0 45 5 equi 44.807580 0.053450 \n", + "38 0 45 5 trap 44.807579 0.053450 \n", + "41 0 45 10 equi 44.807590 0.053447 \n", + "43 0 45 10 trap 44.807586 0.053448 \n", + "46 0 45 20 equi 44.807630 0.053436 \n", + "48 0 45 20 trap 44.807612 0.053441 \n", + "440 60 45 10 appr 45.199472 0.055409 \n", + "445 60 45 20 appr 45.254765 0.070768 \n", + "\n", + " dist (km) dist_err (%) time (s) cached time (s) \n", + "26 1.003375 0.337541 0.000482 0.000093 \n", + "28 1.003375 0.337541 0.000211 0.000211 \n", + "31 2.006751 0.337541 0.000479 0.000094 \n", + "33 2.006751 0.337540 0.000212 0.000212 \n", + "36 5.016877 0.337541 0.000484 0.000093 \n", + "38 5.016877 0.337539 0.000212 0.000212 \n", + "41 10.033754 0.337543 0.000547 0.000100 \n", + "43 10.033754 0.337535 0.000225 0.000225 \n", + "46 20.067510 0.337549 0.000501 0.000096 \n", + "48 20.067504 0.337518 0.000219 0.000219 \n", + "440 9.949885 0.501148 0.000499 0.000092 \n", + "445 19.899823 0.500883 0.000480 0.000083 " + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df[df[\"course_err (%)\"] > 0.05]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Cases with the worst distance error" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
start_latcourse_degdist_kmmethodcourse (deg)course_err (%)dist (km)dist_err (%)time (s)cached time (s)
1001equi0.0000000.0000001.0067390.6739500.0005390.000104
3001trap0.0000000.0000001.0067390.6739500.0002370.000237
6002equi0.0000000.0000002.0134790.6739500.0005080.000097
8002trap0.0000000.0000002.0134790.6739500.0002170.000217
11005equi0.0000000.0000005.0336970.6739490.0005360.000100
13005trap0.0000000.0000005.0336970.6739490.0002140.000214
160010equi0.0000000.00000010.0673950.6739490.0005030.000098
180010trap0.0000000.00000010.0673950.6739490.0002090.000209
210020equi0.0000000.00000020.1347890.6739460.0004910.000095
230020trap0.0000000.00000020.1347890.6739460.0002080.000208
7602101equi209.8336360.0462121.0050590.5058860.0005340.000095
7802101trap209.8336360.0462121.0050590.5058860.0002200.000220
8102102equi209.8336360.0462122.0101180.5058860.0005100.000095
8302102trap209.8336360.0462122.0101180.5058860.0002330.000233
8602105equi209.8336390.0462115.0252940.5058860.0005190.000095
8802105trap209.8336370.0462125.0252940.5058850.0002190.000219
91021010equi209.8336480.04620910.0505890.5058870.0005080.000096
93021010trap209.8336420.04621010.0505880.5058810.0002190.000219
96021020equi209.8336850.04619920.1011780.5058910.0005240.000104
98021020trap209.8336620.04620520.1011740.5058680.0002190.000219
1011501equi0.0000000.0000001.0060620.6061980.0005100.000095
1031501trap0.0000000.0000001.0060620.6061980.0002300.000230
1061502equi0.0000000.0000002.0121230.6061580.0005280.000094
1081502trap0.0000000.0000002.0121230.6061580.0002310.000231
1111505equi0.0000000.0000005.0303020.6060390.0004880.000094
1131505trap0.0000000.0000005.0303020.6060390.0002140.000214
11615010equi0.0000000.00000010.0605840.6058390.0004840.000094
11815010trap0.0000000.00000010.0605840.6058390.0002150.000215
12115020equi0.0000000.00000020.1210880.6054380.0005310.000095
12315020trap0.0000000.00000020.1210880.6054380.0002270.000227
3004501appr0.0000000.0000000.9949800.5019850.0005030.000083
3054502appr0.0000000.0000001.9899590.5020640.0004760.000084
3104505appr0.0000000.0000004.9748850.5023000.0004790.000083
31545010appr0.0000000.0000009.9497310.5026940.0004760.000083
32045020appr0.0000000.00000019.8993040.5034810.0005030.000084
4006001appr0.0000000.0000000.9924750.7524870.0004770.000083
4056002appr0.0000000.0000001.9849490.7525550.0004810.000098
4106005appr0.0000000.0000004.9623620.7527590.0005200.000084
41560010appr0.0000000.0000009.9246900.7530980.0004760.000083
42060020appr0.0000000.00000019.8492450.7537760.0004980.000084
42560451appr45.1498970.0416380.9949870.5013270.0004780.000083
43060452appr45.1553960.0431661.9899740.5013100.0004810.000084
43560455appr45.1719080.0477524.9749370.5012540.0004780.000083
440604510appr45.1994720.0554099.9498850.5011480.0004990.000092
445604520appr45.2547650.07076819.8998230.5008830.0004800.000083
475602101appr210.1213120.0336980.9937320.6267820.0004770.000083
480602102appr210.1174160.0326161.9874650.6267620.0004880.000083
485602105appr210.1057380.0293724.9686650.6266980.0005000.000083
4906021010appr210.0863140.0239769.9373420.6265840.0004780.000166
4956021020appr210.0476150.01322619.8747360.6263210.0004860.000101
\n", + "
" + ], + "text/plain": [ + " start_lat course_deg dist_km method course (deg) course_err (%) \\\n", + "1 0 0 1 equi 0.000000 0.000000 \n", + "3 0 0 1 trap 0.000000 0.000000 \n", + "6 0 0 2 equi 0.000000 0.000000 \n", + "8 0 0 2 trap 0.000000 0.000000 \n", + "11 0 0 5 equi 0.000000 0.000000 \n", + "13 0 0 5 trap 0.000000 0.000000 \n", + "16 0 0 10 equi 0.000000 0.000000 \n", + "18 0 0 10 trap 0.000000 0.000000 \n", + "21 0 0 20 equi 0.000000 0.000000 \n", + "23 0 0 20 trap 0.000000 0.000000 \n", + "76 0 210 1 equi 209.833636 0.046212 \n", + "78 0 210 1 trap 209.833636 0.046212 \n", + "81 0 210 2 equi 209.833636 0.046212 \n", + "83 0 210 2 trap 209.833636 0.046212 \n", + "86 0 210 5 equi 209.833639 0.046211 \n", + "88 0 210 5 trap 209.833637 0.046212 \n", + "91 0 210 10 equi 209.833648 0.046209 \n", + "93 0 210 10 trap 209.833642 0.046210 \n", + "96 0 210 20 equi 209.833685 0.046199 \n", + "98 0 210 20 trap 209.833662 0.046205 \n", + "101 15 0 1 equi 0.000000 0.000000 \n", + "103 15 0 1 trap 0.000000 0.000000 \n", + "106 15 0 2 equi 0.000000 0.000000 \n", + "108 15 0 2 trap 0.000000 0.000000 \n", + "111 15 0 5 equi 0.000000 0.000000 \n", + "113 15 0 5 trap 0.000000 0.000000 \n", + "116 15 0 10 equi 0.000000 0.000000 \n", + "118 15 0 10 trap 0.000000 0.000000 \n", + "121 15 0 20 equi 0.000000 0.000000 \n", + "123 15 0 20 trap 0.000000 0.000000 \n", + "300 45 0 1 appr 0.000000 0.000000 \n", + "305 45 0 2 appr 0.000000 0.000000 \n", + "310 45 0 5 appr 0.000000 0.000000 \n", + "315 45 0 10 appr 0.000000 0.000000 \n", + "320 45 0 20 appr 0.000000 0.000000 \n", + "400 60 0 1 appr 0.000000 0.000000 \n", + "405 60 0 2 appr 0.000000 0.000000 \n", + "410 60 0 5 appr 0.000000 0.000000 \n", + "415 60 0 10 appr 0.000000 0.000000 \n", + "420 60 0 20 appr 0.000000 0.000000 \n", + "425 60 45 1 appr 45.149897 0.041638 \n", + "430 60 45 2 appr 45.155396 0.043166 \n", + "435 60 45 5 appr 45.171908 0.047752 \n", + "440 60 45 10 appr 45.199472 0.055409 \n", + "445 60 45 20 appr 45.254765 0.070768 \n", + "475 60 210 1 appr 210.121312 0.033698 \n", + "480 60 210 2 appr 210.117416 0.032616 \n", + "485 60 210 5 appr 210.105738 0.029372 \n", + "490 60 210 10 appr 210.086314 0.023976 \n", + "495 60 210 20 appr 210.047615 0.013226 \n", + "\n", + " dist (km) dist_err (%) time (s) cached time (s) \n", + "1 1.006739 0.673950 0.000539 0.000104 \n", + "3 1.006739 0.673950 0.000237 0.000237 \n", + "6 2.013479 0.673950 0.000508 0.000097 \n", + "8 2.013479 0.673950 0.000217 0.000217 \n", + "11 5.033697 0.673949 0.000536 0.000100 \n", + "13 5.033697 0.673949 0.000214 0.000214 \n", + "16 10.067395 0.673949 0.000503 0.000098 \n", + "18 10.067395 0.673949 0.000209 0.000209 \n", + "21 20.134789 0.673946 0.000491 0.000095 \n", + "23 20.134789 0.673946 0.000208 0.000208 \n", + "76 1.005059 0.505886 0.000534 0.000095 \n", + "78 1.005059 0.505886 0.000220 0.000220 \n", + "81 2.010118 0.505886 0.000510 0.000095 \n", + "83 2.010118 0.505886 0.000233 0.000233 \n", + "86 5.025294 0.505886 0.000519 0.000095 \n", + "88 5.025294 0.505885 0.000219 0.000219 \n", + "91 10.050589 0.505887 0.000508 0.000096 \n", + "93 10.050588 0.505881 0.000219 0.000219 \n", + "96 20.101178 0.505891 0.000524 0.000104 \n", + "98 20.101174 0.505868 0.000219 0.000219 \n", + "101 1.006062 0.606198 0.000510 0.000095 \n", + "103 1.006062 0.606198 0.000230 0.000230 \n", + "106 2.012123 0.606158 0.000528 0.000094 \n", + "108 2.012123 0.606158 0.000231 0.000231 \n", + "111 5.030302 0.606039 0.000488 0.000094 \n", + "113 5.030302 0.606039 0.000214 0.000214 \n", + "116 10.060584 0.605839 0.000484 0.000094 \n", + "118 10.060584 0.605839 0.000215 0.000215 \n", + "121 20.121088 0.605438 0.000531 0.000095 \n", + "123 20.121088 0.605438 0.000227 0.000227 \n", + "300 0.994980 0.501985 0.000503 0.000083 \n", + "305 1.989959 0.502064 0.000476 0.000084 \n", + "310 4.974885 0.502300 0.000479 0.000083 \n", + "315 9.949731 0.502694 0.000476 0.000083 \n", + "320 19.899304 0.503481 0.000503 0.000084 \n", + "400 0.992475 0.752487 0.000477 0.000083 \n", + "405 1.984949 0.752555 0.000481 0.000098 \n", + "410 4.962362 0.752759 0.000520 0.000084 \n", + "415 9.924690 0.753098 0.000476 0.000083 \n", + "420 19.849245 0.753776 0.000498 0.000084 \n", + "425 0.994987 0.501327 0.000478 0.000083 \n", + "430 1.989974 0.501310 0.000481 0.000084 \n", + "435 4.974937 0.501254 0.000478 0.000083 \n", + "440 9.949885 0.501148 0.000499 0.000092 \n", + "445 19.899823 0.500883 0.000480 0.000083 \n", + "475 0.993732 0.626782 0.000477 0.000083 \n", + "480 1.987465 0.626762 0.000488 0.000083 \n", + "485 4.968665 0.626698 0.000500 0.000083 \n", + "490 9.937342 0.626584 0.000478 0.000166 \n", + "495 19.874736 0.626321 0.000486 0.000101 " + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df[df[\"dist_err (%)\"] > 0.5]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Mean execution time for each method" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
time (s)cached time (s)
method
appr0.0004970.000088
equi0.0005070.000096
geod0.0004600.000196
raye0.0010110.000429
trap0.0002230.000223
\n", + "
" + ], + "text/plain": [ + " time (s) cached time (s)\n", + "method \n", + "appr 0.000497 0.000088\n", + "equi 0.000507 0.000096\n", + "geod 0.000460 0.000196\n", + "raye 0.001011 0.000429\n", + "trap 0.000223 0.000223" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.groupby([\"method\"]).mean().iloc[:, -2:]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Conclusion: `geod` offers the best accuracy (uses accurate model of the Earth) and is 2x faster than Raye's method." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/local_pathfinding/linear_interpolation.ipynb b/notebooks/local_pathfinding/linear_interpolation.ipynb new file mode 100644 index 000000000..f65f6010b --- /dev/null +++ b/notebooks/local_pathfinding/linear_interpolation.ipynb @@ -0,0 +1,156 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Boat Speed: 18.5 km/h\n" + ] + } + ], + "source": [ + "BOATSPEEDS = [\n", + " [0, 0, 0, 0, 0, 0, 0],\n", + " [0, 0, 0.4, 1.1, 3.2, 3.7, 2.8],\n", + " [0, 0.3, 1.9, 3.7, 9.3, 13.0, 9.2],\n", + " [0, 0.9, 3.7, 7.4, 14.8, 18.5, 13.0],\n", + " [0, 1.3, 5.6, 9.3, 18.5, 24.1, 18.5],\n", + "]\n", + "\n", + "WINDSPEEDS = [0, 9.3, 18.5, 27.8, 37.0] # The row labels\n", + "ANGLES = [0, 20, 30, 45, 90, 135, 180] # The column labels\n", + "\n", + "\n", + "def interpolate_boat_speed(true_windspeed, sailing_angle):\n", + " sailing_angle = abs(sailing_angle)\n", + "\n", + " # Find the nearest windspeed values above and below the true windspeed\n", + " lower_windspeed_index = max([i for i, ws in enumerate(WINDSPEEDS) if ws <= true_windspeed])\n", + " upper_windspeed_index = (\n", + " lower_windspeed_index + 1\n", + " if lower_windspeed_index < len(WINDSPEEDS) - 1\n", + " else lower_windspeed_index\n", + " )\n", + "\n", + " # Find the nearest angle values above and below the sailing angle\n", + " lower_angle_index = max([i for i, ang in enumerate(ANGLES) if ang <= sailing_angle])\n", + " upper_angle_index = (\n", + " lower_angle_index + 1 if lower_angle_index < len(ANGLES) - 1 else lower_angle_index\n", + " )\n", + "\n", + " # Find the maximum angle and maximum windspeed based on the actual data in the table\n", + " max_angle = max(ANGLES)\n", + " max_windspeed = max(WINDSPEEDS)\n", + "\n", + " # Handle the case of maximum angle (use the dynamic max_angle)\n", + " if upper_angle_index == len(ANGLES) - 1:\n", + " lower_angle_index = ANGLES.index(max_angle) - 1\n", + " upper_angle_index = ANGLES.index(max_angle)\n", + "\n", + " # Handle the case of the maximum windspeed (use the dynamic max_windspeed)\n", + " if upper_windspeed_index == len(WINDSPEEDS) - 1:\n", + " lower_windspeed_index = WINDSPEEDS.index(max_windspeed) - 1\n", + " upper_windspeed_index = WINDSPEEDS.index(max_windspeed)\n", + "\n", + " # Perform linear interpolation\n", + " lower_windspeed = WINDSPEEDS[lower_windspeed_index]\n", + " upper_windspeed = WINDSPEEDS[upper_windspeed_index]\n", + " lower_angle = ANGLES[lower_angle_index]\n", + " upper_angle = ANGLES[upper_angle_index]\n", + "\n", + " boat_speed_lower = BOATSPEEDS[lower_windspeed_index][lower_angle_index]\n", + " boat_speed_upper = BOATSPEEDS[upper_windspeed_index][lower_angle_index]\n", + "\n", + " interpolated_1 = boat_speed_lower + (true_windspeed - lower_windspeed) * (\n", + " boat_speed_upper - boat_speed_lower\n", + " ) / (upper_windspeed - lower_windspeed)\n", + "\n", + " boat_speed_lower = BOATSPEEDS[lower_windspeed_index][upper_angle_index]\n", + " boat_speed_upper = BOATSPEEDS[upper_windspeed_index][upper_angle_index]\n", + "\n", + " interpolated_2 = boat_speed_lower + (true_windspeed - lower_windspeed) * (\n", + " boat_speed_upper - boat_speed_lower\n", + " ) / (upper_windspeed - lower_windspeed)\n", + "\n", + " interpolated_value = interpolated_1 + (sailing_angle - lower_angle) * (\n", + " interpolated_2 - interpolated_1\n", + " ) / (upper_angle - lower_angle)\n", + "\n", + " return interpolated_value" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "import pytest\n", + "\n", + "\n", + "def test_interpolate_boat_speed(true_windspeed, sailing_angle, answer):\n", + " boat_speed = interpolate_boat_speed(true_windspeed, sailing_angle)\n", + " return boat_speed == pytest.approx(answer)\n", + "\n", + "\n", + "# Corners of the table\n", + "assert test_interpolate_boat_speed(0, 0, 0)\n", + "assert test_interpolate_boat_speed(37.0, 180, 18.5)\n", + "assert test_interpolate_boat_speed(0, 180, 0)\n", + "assert test_interpolate_boat_speed(37.0, 0, 0)\n", + "\n", + "# Windspeed is 0 and we have an angle\n", + "assert test_interpolate_boat_speed(0, 12, 0)\n", + "assert test_interpolate_boat_speed(0, 20, 0)\n", + "assert test_interpolate_boat_speed(0, 30, 0)\n", + "assert test_interpolate_boat_speed(0, 135, 0)\n", + "assert test_interpolate_boat_speed(0, 148.2, 0)\n", + "\n", + "# Angle is 0 and we have a windspeed\n", + "assert test_interpolate_boat_speed(9.3, 0, 0)\n", + "assert test_interpolate_boat_speed(32.3, 0, 0)\n", + "assert test_interpolate_boat_speed(37.0, 0, 0)\n", + "\n", + "# Other edge cases\n", + "assert test_interpolate_boat_speed(10.6, 180, 3.704347826)\n", + "assert test_interpolate_boat_speed(37.0, 35.0, 6.833333333)\n", + "assert test_interpolate_boat_speed(27.8, 102.7, 15.844222222)\n", + "assert test_interpolate_boat_speed(14.4, 30.0, 1.231521739)\n", + "\n", + "# Both angle and windspeed have are directly in the table\n", + "assert test_interpolate_boat_speed(18.5, 45, 3.7)\n", + "\n", + "# General case between angles\n", + "assert test_interpolate_boat_speed(12.0, 60, 2.905434783)\n", + "assert test_interpolate_boat_speed(5.3, 13.9, 0)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/sailbot.code-workspace b/sailbot.code-workspace index 7d4c06398..24223190c 100644 --- a/sailbot.code-workspace +++ b/sailbot.code-workspace @@ -3,9 +3,6 @@ { "path": "." }, - { - "path": "docs" - }, { "path": "src/boat_simulator" }, @@ -30,12 +27,6 @@ { "path": "src/network_systems" }, - { - "path": "src/notebooks" - }, - { - "path": "src/raye-local-pathfinding" - }, { "path": "src/website" }, @@ -209,9 +200,6 @@ "isort.interpreter": ["/usr/bin/python3"], // test framework: pytest "python.testing.cwd": "/workspaces/sailbot_workspace/src", - "python.testing.pytestArgs": [ - "--ignore=/workspaces/sailbot_workspace/src/raye-local-pathfinding", - ], "python.testing.pytestEnabled": true, // type checker: mypy "mypy-type-checker.args": [ @@ -258,6 +246,9 @@ // markdown // copied from docs: https://github.com/UBCSailbot/docs/blob/main/.vscode/settings.json "[markdown]": { + "editor.codeActionsOnSave": { + "source.fixAll": "always" + }, "editor.unicodeHighlight.ambiguousCharacters": false, "editor.unicodeHighlight.invisibleCharacters": false, "editor.wordWrap": "on", @@ -494,13 +485,6 @@ "problemMatcher": [] }, // Workspace editing tasks - { - "label": "clone source repositories", - "detail": "Clone the repositories specified in src/polaris.repos to src/", - "type": "shell", - "command": "vcs import < src/polaris.repos src --skip-existing", - "problemMatcher": [] - }, { "label": "setup", "detail": "Set up the workspace", diff --git a/setup.sh b/setup.sh index 2810ac16e..ab0425f87 100755 --- a/setup.sh +++ b/setup.sh @@ -1,20 +1,6 @@ #!/bin/bash set -e -# Display warning message -function warn() { - message=$1 - echo -e "\e[1;33m${message}\e[0m" -} - -# Import all project repositories -if [[ $DISABLE_VCS != "true" ]]; then - echo "Importing project repositories..." - vcs import < src/polaris.repos src --skip-existing -else - warn "VCS disabled. Skipping project repository imports..." -fi - sudo apt-get update rosdep update --rosdistro $ROS_DISTRO rosdep install --from-paths src --ignore-src -y --rosdistro $ROS_DISTRO diff --git a/src/boat_simulator/.gitignore b/src/boat_simulator/.gitignore new file mode 100644 index 000000000..0af0477bb --- /dev/null +++ b/src/boat_simulator/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Generated directories from ROS +build/* +install/* +log/* diff --git a/src/boat_simulator/LICENSE b/src/boat_simulator/LICENSE new file mode 100644 index 000000000..9cf106272 --- /dev/null +++ b/src/boat_simulator/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/boat_simulator/README.md b/src/boat_simulator/README.md new file mode 100644 index 000000000..26ce3f565 --- /dev/null +++ b/src/boat_simulator/README.md @@ -0,0 +1,31 @@ +# UBC Sailbot Boat Simulator + +[![Tests](https://github.com/UBCSailbot/boat_simulator/actions/workflows/tests.yml/badge.svg)](https://github.com/UBCSailbot/boat_simulator/actions/workflows/tests.yml) + +UBC Sailbot's boat simulator for the new project. This repository contains a ROS package `boat_simulator`. This README +contains only setup and run instructions. Further information on the boat simulator can be found on the software +team's [docs website](https://ubcsailbot.github.io/sailbot_workspace/main/current/boat_simulator/overview/). + +## Setup + +The boat simulator is meant to be ran inside the [Sailbot Workspace](https://github.com/UBCSailbot/sailbot_workspace) +development environment. Follow the setup instructions for the Sailbot Workspace +[here](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/setup/) +to get started and build all the necessary ROS packages. + +## Run + +The [`launch/`](./launch/) folder contains a [ROS 2 launch file](https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/Launch-Main.html) +responsible for starting up the boat simulator. To run the boat simulator standalone, execute the launch file after building +the `boat_simulator` package: + +``` shell +ros2 launch boat_simulator main_launch.py [OPTIONS]... +``` + +To see a list of options for simulator configuration, add the `-s` flag at the end of the above command. + +## Test + +Run the `test` task in the Sailbot Workspace. See [here](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) +on how to run vscode tasks. diff --git a/src/boat_simulator/boat_simulator/__init__.py b/src/boat_simulator/boat_simulator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/boat_simulator/common/__init__.py b/src/boat_simulator/boat_simulator/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/boat_simulator/common/constants.py b/src/boat_simulator/boat_simulator/common/constants.py new file mode 100644 index 000000000..0fefb46c2 --- /dev/null +++ b/src/boat_simulator/boat_simulator/common/constants.py @@ -0,0 +1,63 @@ +"""Constants used across the boat simulator package.""" + +import os +from dataclasses import dataclass +from enum import Enum + + +# Class declarations for constants. These are not meant to be accessed directly. +@dataclass +class Actions: + RUDDER_ACTUATION: str = "rudder_actuation" + SAIL_ACTUATION: str = "sail_trim_tab_actuation" + + +@dataclass +class LowLevelControlSubscriptionTopics: + GPS: str = "mock_gps" + + +@dataclass +class PhysicsEngineSubscriptionTopics: + DESIRED_HEADING: str = "desired_heading" + SAIL_TRIM_TAB_ANGLE: str = "sail_cmd" + + +@dataclass +class PhysicsEnginePublisherTopics: + GPS: str = "mock_gps" + KINEMATICS: str = "mock_kinematics" + WIND_SENSORS: str = "mock_wind_sensors" + + +# Directly accessible constants + +# Boat simulator ROS action names +ACTION_NAMES = Actions() + +# Base directory to store the output data from the data collection node +DATA_COLLECTION_OUTPUT_DIR = os.path.join(str(os.getenv("ROS_WORKSPACE")), "boat_simulator_output") + +# ROS topic names for the low level control node subscriptions +LOW_LEVEL_CTRL_SUBSCRIPTIONS = LowLevelControlSubscriptionTopics() + +# CLI argument name for multithreading option for physics engine +MULTITHREADING_CLI_ARG_NAME = "--enable-multithreading" + +# ROS topic names for physics engine publishers +PHYSICS_ENGINE_PUBLISHERS = PhysicsEnginePublisherTopics() + +# ROS topic names for physics engine subscriptions +PHYSICS_ENGINE_SUBSCRIPTIONS = PhysicsEngineSubscriptionTopics() + +# CLI argument name for data collection option +DATA_COLLECTION_CLI_ARG_NAME = "--enable-data-collection" + +# Enumerated orientation indices since indexing pitch, roll, and yaw could be arbitrary +ORIENTATION_INDICES = Enum("ORIENTATION_INDICES", ["PITCH", "ROLL", "YAW"], start=0) # x, y, x + +# Number of times the sail action server routine's main loop executes +SAIL_ACTUATION_NUM_LOOP_EXECUTIONS = 10 # TODO This is a placeholder until the ctrl is integrated + +# Number of times the rudder action server routine's main loop executes +RUDDER_ACTUATION_NUM_LOOP_EXECUTIONS = 10 # TODO This is a placeholder until the PID is integrated diff --git a/src/boat_simulator/boat_simulator/common/generators.py b/src/boat_simulator/boat_simulator/common/generators.py new file mode 100644 index 000000000..2bb94a1db --- /dev/null +++ b/src/boat_simulator/boat_simulator/common/generators.py @@ -0,0 +1,168 @@ +"""Random vector generator classes.""" + +from abc import ABC, abstractmethod + +import numpy as np +from numpy.typing import NDArray + +from boat_simulator.common.types import Scalar, ScalarOrArray + + +class VectorGenerator(ABC): + """This class's purpose is to generate values in a sequence. It acts as a base class for other + generators. + + Attributes: + seed (int): The seed used to seed the random number generator. + rng (np.random.Generator): The seeded random number generator. + """ + + def __init__(self, seed: int = 0): + """Initializes an instance of `VectorGenerator`. Note that this class cannot be + instantiated directly since it is abstract. + + Args: + seed (int, optional): The seed used to seed the random number generator + (if used at all). Defaults to 0. + """ + self.__seed = seed + self.__rng = np.random.default_rng(seed=seed) + + def next(self) -> ScalarOrArray: + """Generates the next value in the sequence. This function acts as an alias to the + function _next(). + + Returns: + ScalarOrArray: Generated value. + """ + return self._next() + + @abstractmethod + def _next(self) -> ScalarOrArray: + """Generates the next value in the sequence. + + Returns: + ScalarOrArray: Generated array. + """ + pass + + @property + def seed(self) -> int: + return self.__seed + + @property + def rng(self) -> np.random.Generator: + return self.__rng + + +class GaussianGenerator(VectorGenerator): + """This class generates random scalars using a univariate gaussian distribution. + + Attributes: + mean (Scalar): The mean of the gaussian distribution. + stdev (Scalar): The standard deviation of the gaussian distribution. This value is + strictly positive. + value (Scalar): The latest generated scalar. + + Extends: VectorGenerator + """ + + def __init__(self, mean: Scalar, stdev: Scalar, seed: int = 0): + """Initializes an instance of GaussianGenerator. + + Args: + mean (Scalar): _description_ + stdev (Scalar): _description_ + seed (int, optional): _description_. Defaults to 0. + """ + super().__init__(seed=seed) + self.__mean = mean + self.__stdev = stdev + self.next() + + def _next(self) -> Scalar: + self.__value = np.random.normal(self.mean, self.stdev) + return self.__value + + @property + def mean(self) -> Scalar: + return self.__mean + + @property + def stdev(self) -> Scalar: + return self.__stdev + + @property + def value(self) -> Scalar: + return self.__value + + +class MVGaussianGenerator(VectorGenerator): + """This class generates random vectors using a multivariate gaussian distribution. + + Attributes: + mean (NDArray): The mean of the gaussian distribution. Shape should be (N,). + cov (NDArray): The covariance matrix of the gaussian distribution. Should be positive + semi-definite and have a shape of (N,N). + value (NDArray): The latest generated array. + + Extends: VectorGenerator + """ + + def __init__(self, mean: NDArray, cov: NDArray, seed: int = 0): + """Initializes an instance of MVGaussianGenerator. + + Args: + mean (NDArray): The mean of the gaussian distribution. Shape should be (N,). + cov (NDArray): The covariance matrix of the gaussian distribution. Should be positive + semi-definite and have a shape of (N,N). + seed (int, optional): The seed that seeds the random number generator. Defaults to 0. + """ + super().__init__(seed=seed) + self.__mean = mean + self.__cov = cov + self.next() + + def _next(self) -> NDArray: + self.__value = self.rng.multivariate_normal(self.mean, self.cov) + return self.__value + + @property + def mean(self) -> NDArray: + return self.__mean + + @property + def cov(self) -> NDArray: + return self.__cov + + @property + def value(self) -> NDArray: + return self.__value + + +class ConstantGenerator(VectorGenerator): + """This class returns the same specified value when asked to generate a new value. + + Attributes: + constant (ScalarOrArray): The constant value to return upon generation. It can + either be a scalar or an array. + + Extends: VectorGenerator + """ + + def __init__(self, constant: ScalarOrArray): + """Initializes an instance of ConstantGenerator. + + Args: + constant (ScalarOrArray): The constant value to return upon generation. It can + either be a scalar or an array. + """ + super().__init__(seed=0) + self.__constant = constant + + def _next(self) -> ScalarOrArray: + return self.__constant + + @property + def constant(self) -> ScalarOrArray: + return self.__constant diff --git a/src/boat_simulator/boat_simulator/common/sensors.py b/src/boat_simulator/boat_simulator/common/sensors.py new file mode 100644 index 000000000..a03940815 --- /dev/null +++ b/src/boat_simulator/boat_simulator/common/sensors.py @@ -0,0 +1,157 @@ +from dataclasses import dataclass + +from typing import Optional, Any +from numpy.typing import NDArray + + +from boat_simulator.common.types import Scalar, ScalarOrArray + +from boat_simulator.common.generators import ( + ConstantGenerator, + MVGaussianGenerator, + GaussianGenerator, +) + +WindSensorGenerators = Optional[MVGaussianGenerator | ConstantGenerator] +GPSGenerators = Optional[GaussianGenerator | ConstantGenerator] + + +@dataclass +class Sensor: + """Interface for sensors in the Boat Simulation.""" + + def update(self, **kwargs): + """ + Update attributes in Sensor using keyword arguments. + + Usage: Sensor.update(attr1=val1, attr2=val2, ...) + + Raises: + ValueError: If kwarg is not a defined attribute in Sensor + """ + for attr_name, attr_val in kwargs.items(): + if attr_name in self.__annotations__: + setattr(self, attr_name, attr_val) + else: + raise ValueError( + f"{attr_name} not a property in {self.__class__.__name__} \ + expected one of {self.__annotations__}" + ) + + def read(self, key: str) -> Any: + """ + Read the value from an attribute in Sensor. + + Args: + key (str): Attribute name to read from + + Raises: + ValueError: If key is not an a defined attribute in Sensor + + Returns: + Any: Value stored in attribute with name supplied in "key" argument + """ + if key in self.__annotations__: + return getattr(self, key) + else: + raise ValueError( + f"{key} not a property in {self.__class__.__name__}. \ + Available keys: {self.__annotations__}" + ) + + +@dataclass +class WindSensor(Sensor): + """ + Abstraction for wind sensor. + + # TODO: Add delay functions. + + Properties: + wind (ScalarOrArray): Wind x, y components or single value + wind_noisemaker (Optional[MVGaussianGenerator | ConstantGenerator]): + Noise function to emulate sensor noise in wind data reading + """ + + wind: ScalarOrArray + wind_noisemaker: WindSensorGenerators = None + + @property # type: ignore + def wind(self) -> ScalarOrArray: + # TODO: Ensure attribute value and noisemakers are using the same value shape. + # - wind scalars should add with noise scalars. + # - wind vectors should add with noise vectors. + # Could consider using a __post_init__ function for this + return ( + self._wind + self.wind_noisemaker.next() # type: ignore + if self.wind_noisemaker is not None + else self._wind + ) + + @wind.setter + def wind(self, wind: ScalarOrArray): + self._wind = wind + + +@dataclass +class GPS(Sensor): + """ + Abstraction for GPS. + + # TODO: Add delay functions. + + Properties: + lat_lon (NDArray): Boat latitude and longitude (2x1 array) + speed (Scalar): Boat speed + heading (Scalar): Boat heading + lat_lon_noisemaker (Optional[GaussianGenerator | ConstantGenerator]): + Noise function to emulate sensor noise in latitude and longitude readings + speed_noisemaker (Optional[GaussianGenerator | ConstantGenerator]): + Noise function to emulate sensor noise in speed readings + heading_noisemaker (Optional[GaussianGenerator | ConstantGenerator]): + Noise function to emulate sensor noise in heading readings + """ + + lat_lon: NDArray + speed: Scalar + heading: Scalar + + lat_lon_noisemaker: GPSGenerators = None + speed_noisemaker: GPSGenerators = None + heading_noisemaker: GPSGenerators = None + + @property # type: ignore + def lat_lon(self) -> NDArray: + return ( + self._lat_lon + self.lat_lon_noisemaker.next() + if self.lat_lon_noisemaker is not None + else self._lat_lon + ) + + @lat_lon.setter + def lat_lon(self, lat_lon: NDArray): + self._lat_lon = lat_lon + + @property # type: ignore + def speed(self) -> Scalar: + return ( + self._speed + self.speed_noisemaker.next() # type: ignore + if self.speed_noisemaker is not None + else self._speed + ) + + @speed.setter + def speed(self, speed: Scalar): + self._speed = speed + + @property # type: ignore + def heading(self) -> Scalar: + return ( + self._heading + self.heading_noisemaker.next() # type: ignore + if self.heading_noisemaker is not None + else self._heading + ) + + @heading.setter + def heading(self, heading: Scalar): + self._heading = heading diff --git a/src/boat_simulator/boat_simulator/common/types.py b/src/boat_simulator/boat_simulator/common/types.py new file mode 100644 index 000000000..1aed4ec47 --- /dev/null +++ b/src/boat_simulator/boat_simulator/common/types.py @@ -0,0 +1,16 @@ +"""Custom types used for type hinting in the boat simulator package.""" + +from enum import Enum +from typing import TypeVar, Union + +import numpy as np +from numpy.typing import NDArray + +# Any attribute of a class that extends Enum +EnumAttr = TypeVar("EnumAttr", bound=Enum) + +# Scalar value that can be an integer or a float +Scalar = Union[int, float] + +# Used in cases where support for scalars or arrays of scalars are needed. +ScalarOrArray = Union[Scalar, NDArray[Union[np.int32, np.float32]]] diff --git a/src/boat_simulator/boat_simulator/common/unit_conversions.py b/src/boat_simulator/boat_simulator/common/unit_conversions.py new file mode 100644 index 000000000..701b443a5 --- /dev/null +++ b/src/boat_simulator/boat_simulator/common/unit_conversions.py @@ -0,0 +1,231 @@ +"""Unit conversion logic, mostly contained in the `UnitConverter` class.""" + +from __future__ import annotations + +import math +from enum import Enum +from typing import Dict + +from boat_simulator.common.types import EnumAttr, Scalar, ScalarOrArray + + +class ConversionFactor: + """Performs unit conversions from one unit to another. Both directions of unit conversion are + supported by this class. + + Attributes: + `factor` (Scalar): The conversion factor to go from unit A to B. + `inverse_factor` (Scalar): The conversion factor to go from unit B to A. + """ + + def __init__(self, factor: Scalar): + """Initializes an instance of `ConversionFactor`. + + Args: + factor (Scalar): Conversion factor from unit A to B. + """ + self.__factor = factor + + def forward_convert(self, value: ScalarOrArray) -> ScalarOrArray: + """Convert from unit A to B. + + Args: + value (ScalarOrArray): Values with unit A to be converted. + + Returns: + ScalarOrArray: Converted values with unit B. + """ + return value * self.factor + + def backward_convert(self, value: ScalarOrArray) -> ScalarOrArray: + """Convert from unit B to A. + + Args: + value (ScalarOrArray): Values with unit B to be converted. + + Returns: + ScalarOrArray: Converted values with unit A. + """ + return value * self.inverse_factor + + def inverse(self) -> ConversionFactor: + """ + Get the inverse of this class containing the inverse conversion factor. + + Returns: + ConversionFactor: The inverse of this class. + """ + return ConversionFactor(factor=self.inverse_factor) + + def __mul__(self, other: ConversionFactor) -> ConversionFactor: + """Multiplication operator between two `ConversionFactor` objects (A * B). + + Args: + other (ConversionFactor): Other conversion factor being multiplied. + + Returns: + ConversionFactor: Multiplied conversion factor. + """ + mul_conversion_factor = self.factor * other.factor + return ConversionFactor(factor=mul_conversion_factor) + + def __rmul__(self, other: ConversionFactor) -> ConversionFactor: + """Multiplication operator between two `ConversionFactor` objects (B * A). + + Args: + other (ConversionFactor): Other conversion factor being multiplied. + + Returns: + ConversionFactor: Multiplied conversion factor. + """ + return self.__mul__(other) + + @property + def factor(self) -> Scalar: + return self.__factor + + @property + def inverse_factor(self) -> Scalar: + return 1 / self.factor + + +class ConversionFactors(Enum): + """Predefined conversion factors commonly used in that boat simulator. This class is meant + to be used in conjunction with the `UnitConverter` class to specify the unit conversions + that will be performed. + + Attributes: + _to_ (EnumAttr): `ConversionFactor` classes to perform unit + conversions going from unit A to B. + + Attributes in this class must follow the above naming convention. + """ + + # Length + + km_to_m = ConversionFactor(factor=1000) + m_to_km = km_to_m.inverse() + + m_to_cm = ConversionFactor(factor=100) + cm_to_m = m_to_cm.inverse() + + km_to_cm = km_to_m * m_to_cm + cm_to_km = km_to_cm.inverse() + + m_to_ft = ConversionFactor(factor=3.28084) + ft_to_m = m_to_ft.inverse() + + mi_to_ft = ConversionFactor(factor=5280) + ft_to_mi = mi_to_ft.inverse() + + mi_to_m = ConversionFactor(factor=1609.344) + m_to_mi = mi_to_m.inverse() + + mi_to_km = mi_to_m * m_to_km + km_to_mi = mi_to_km.inverse() + + nautical_mi_to_mi = ConversionFactor(factor=1.15078) + mi_to_nautical_mi = nautical_mi_to_mi.inverse() + + nautical_mi_to_km = ConversionFactor(factor=1.852) + km_to_nautical_mi = nautical_mi_to_km.inverse() + + # Time + min_to_sec = ConversionFactor(factor=60) + sec_to_min = min_to_sec.inverse() + + h_to_min = ConversionFactor(factor=60) + min_to_h = h_to_min.inverse() + + h_to_sec = h_to_min * min_to_sec + sec_to_h = h_to_sec.inverse() + + # Speed + miPh_to_kmPh = ConversionFactor(factor=1.609344) + kmPh_to_miPh = miPh_to_kmPh.inverse() + + mPs_to_kmPh = ConversionFactor(factor=3.6) + kmPh_to_mPs = mPs_to_kmPh.inverse() + + knots_to_kmPh = ConversionFactor(factor=1.852) + kmPh_to_knots = knots_to_kmPh.inverse() + + knots_to_miPh = ConversionFactor(factor=1.15077945) + miPh_to_knots = knots_to_miPh.inverse() + + # Acceleration + + miPs2_to_mPs2 = mi_to_m + mPs2_to_miPs2 = m_to_mi + + kmPs2_to_mPs2 = km_to_m + mPs2_to_kmPs2 = m_to_km + + mPs2_to_knotsPs2 = ConversionFactor(factor=1.94384466) + knotsPs2_to_mPs2 = mPs2_to_knotsPs2.inverse() + + # Mass + + kg_to_g = ConversionFactor(factor=1000) + g_to_kg = kg_to_g.inverse() + + lb_to_g = ConversionFactor(factor=453.59237) + g_to_lb = lb_to_g.inverse() + + kg_to_lb = ConversionFactor(factor=2.2046226218) + lb_to_kg = kg_to_lb.inverse() + + # Rotation + + degrees_to_rad = ConversionFactor(factor=math.pi / 180) + rad_to_degrees = degrees_to_rad.inverse() + + +class UnitConverter: + """Performs multiple unit conversions at once. + + Attributes: + (EnumAttr): Attribute names of this class depend on what is passed into + the __init__ function of this class. All attributes are of type `EnumAttr`, which + should come from the `ConversionFactors` class. + """ + + def __init__(self, **kwargs: EnumAttr): + """Initializes an instance of `UnitConverter`. + + Args: + kwargs (Dict[str, EnumAttr]): Dictionary keys are class attribute names, and dictionary + values are class attribute values. Dictionary values are strictly class attributes + belonging to `ConversionFactors`. + """ + for attr_name, attr_val in kwargs.items(): + assert isinstance(attr_val, Enum) and isinstance(attr_val.value, ConversionFactor) + setattr(self, attr_name, attr_val) + + def convert(self, **kwargs: ScalarOrArray) -> Dict[str, ScalarOrArray]: + """Perform unit conversions for multiple specified values. + + Pre-Condition: + Unit conversions are only done on comparable ScalarOrArray. Ex: Length to length + + Args: + kwargs (Dict[str, ScalarOrArray]): Dictionary keys are strictly names + of attributes belonging to this class. + Dictionary values are the values to be converted, using the + conversion factor corresponding to the class attribute. + + Returns: + Dict[str, ScalarOrArray]: Converted values. Dictionary keys are class + attribute names corresponding to the converted value. Dictionary values are the + converted values. + """ + converted_values: Dict[str, ScalarOrArray] = {} + + for attr_name, attr_val in kwargs.items(): + attr = getattr(self, attr_name, None) + assert attr is not None, f"Attribute name {attr} not found in UnitConverter." + + conversion_factor = attr.value + converted_values[attr_name] = conversion_factor.forward_convert(attr_val) + + return converted_values diff --git a/src/boat_simulator/boat_simulator/common/utils.py b/src/boat_simulator/boat_simulator/common/utils.py new file mode 100644 index 000000000..e701518d4 --- /dev/null +++ b/src/boat_simulator/boat_simulator/common/utils.py @@ -0,0 +1,77 @@ +"""Useful functions that could be used anywhere in the boat simulator package.""" + +import math +from typing import Union, overload + +import numpy as np +from numpy.typing import NDArray + +from boat_simulator.common.types import Scalar, ScalarOrArray + + +def rad_to_degrees(angle: Scalar) -> Scalar: + """Converts an angle from radians to degrees. + + Args: + `angle` (Scalar): Angle in radians. + + Returns: + Scalar: Angle in degrees. + """ + return angle * (180 / math.pi) + + +def degrees_to_rad(angle: Scalar) -> Scalar: + """Converts an angle from degrees to radians. + + Args: + `angle` (Scalar): Angle in degrees. + + Returns: + Scalar: Angle in radians. + """ + return angle * (math.pi / 180) + + +@overload +def bound_to_180(angle: Scalar, isDegrees: bool = True) -> Scalar: + ... + + +@overload +def bound_to_180( + angle: NDArray[Union[np.int32, np.float32]], isDegrees: bool = True +) -> NDArray[Union[np.int32, np.float32]]: + ... + + +def bound_to_180(angle: ScalarOrArray, isDegrees: bool = True) -> ScalarOrArray: + """Converts all angles to be within the range [-180, 180) degrees or [-π, π) radians. + + Args: + `angles` (ScalarOrArray): Angle(s) to be bound. + `isDegrees` (bool, optional): True if the input is in degrees, and false for radians. + Defaults to True. + + Returns: + ScalarOrArray: Bounded angle(s). Output unit matches `isDegrees`. + """ + bound = 180 if isDegrees else math.pi + return angle - 2 * bound * ((angle + bound) // (2 * bound)) + + +def bound_to_360(angle: ScalarOrArray, isDegrees: bool = True) -> ScalarOrArray: + """Converts an angle to be in the range [0, 360) degrees. + + Args: + `angle` (ScalarOrArray): Angle to be bound. + `isDegrees` (bool, optional): True if the input is in degrees, and false for radians. + Defaults to True. + + Returns: + ScalarOrArray: Bounded angle. Output units matches `isDegrees`. + """ + bound = 360 if isDegrees else (2 * math.pi) + bound_angle = angle % bound + + return bound_angle diff --git a/src/boat_simulator/boat_simulator/nodes/__init__.py b/src/boat_simulator/boat_simulator/nodes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/boat_simulator/nodes/data_collection/__init__.py b/src/boat_simulator/boat_simulator/nodes/data_collection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/boat_simulator/nodes/data_collection/data_collection_node.py b/src/boat_simulator/boat_simulator/nodes/data_collection/data_collection_node.py new file mode 100644 index 000000000..38dd3ace7 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/data_collection/data_collection_node.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 + +"""The ROS node for data collection.""" +import inspect +import json +import os +import signal +import sys +from typing import Any, Type + +import custom_interfaces.msg +import rclpy +import rclpy.utilities +import rosbag2_py +import rosidl_runtime_py +from rclpy.node import Node +from rclpy.serialization import serialize_message + +import boat_simulator.common.constants as Constants + + +def shutdown_handler(signum: int, frame: Any) -> None: + """Exit the program gracefully in response to a shutdown signal. This function is necessary for + the ROS shutdown callback to be properly called. + + Args: + signum (int): The signal number associated with the signal received. + frame (Any): The current execution frame at the time the signal was received. + """ + sys.exit(0) + + +def main(args=None): + rclpy.init(args=args) + node = DataCollectionNode() + if is_collection_enabled(): + try: + # TODO Explore alternatives to using the signal library, such as ROS event handlers + signal.signal(signal.SIGINT, shutdown_handler) + rclpy.spin(node) + finally: + rclpy.shutdown() + + +def is_collection_enabled() -> bool: + try: + is_data_collection_enabled_index = ( + sys.argv.index(Constants.DATA_COLLECTION_CLI_ARG_NAME) + 1 + ) + is_data_collection_enabled = sys.argv[is_data_collection_enabled_index] == "true" + except ValueError: + is_data_collection_enabled = False + return is_data_collection_enabled + + +class DataCollectionNode(Node): + # TODO: Abstract the file writing operations and remove redundant checks for self.use_json and + # self.use_bag. + + def __init__(self): + super().__init__(node_name="data_collection_node") + self.get_logger().debug("Initializing node...") + self.__declare_ros_parameters() + self.__init_msg_types_dict() + self.__init_subscriptions() + self.__init_io() + self.__init_timer_callbacks() + self.__init_shutdown_callbacks() + self.get_logger().debug("Node initialization complete. Starting execution...") + + def __declare_ros_parameters(self): + """Declares ROS parameters from the global configuration file that will be used in this + node. + """ + self.get_logger().debug("Declaring ROS parameters...") + + # TODO: Implement a CLI argument in the launch file to enable storing JSON in a human- + # readable format as an option, ensuring default behavior prioritizes efficiency over human + # readability in file writing. + self.declare_parameters( + namespace="", + parameters=[ + ("file_name", rclpy.Parameter.Type.STRING), + ("qos_depth", rclpy.Parameter.Type.INTEGER), + ("topics", rclpy.Parameter.Type.STRING_ARRAY), + ("bag", rclpy.Parameter.Type.BOOL), + ("json", rclpy.Parameter.Type.BOOL), + ("write_period_sec", rclpy.Parameter.Type.DOUBLE), + ], + ), + all_parameters = self._parameters + for name, parameter in all_parameters.items(): + value_str = str(parameter.value) + self.get_logger().debug(f"Got parameter {name} with value {value_str}") + + def __init_msg_types_dict(self): + """Prepare dictionary of all msg types with key name and value class""" + self.get_logger().debug("Initializing msg types dictionary...") + + self.__msg_types_dict = {} + for name, cls in inspect.getmembers(custom_interfaces.msg, inspect.isclass): + # Do not store hidden classes (which often start with "_" by convention) + if not name.startswith("_"): + self.__msg_types_dict[name] = cls + + def __init_subscriptions(self): + """Initialize the subscriptions for this node. These subscriptions pertain to the topics + from which data will be collected.""" + self.get_logger().debug("Initializing subscriptions...") + + self.__sub_topic_names = {} + + # Get topic names by extracting from all evenly indexed elements and all topic types from + # oddly indexed elements since it is assumed that topic name and type alternate in the list + # For example, [name1, type1, name2, type2, ...] + topic_names = self.sub_topics[::2] + topic_types = self.sub_topics[1::2] + for topic_name, msg_type_name in zip(topic_names, topic_types): + if msg_type_name not in self.__msg_types_dict: + self.get_logger().error( + f"msg type {msg_type_name} does not exist. Please adjust the topics array in \ + the boat simulator configuration file" + ) + continue + + # Create subscription to each topic specified in the config file + self.__sub_topic_names[topic_name] = msg_type_name + self.create_subscription( + msg_type=self.__msg_types_dict[msg_type_name], + topic=topic_name, + callback=lambda msg, tn=topic_name: self.__general_sub_callback(msg, tn), + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + ) + + def __init_io(self): + """Initialize JSON and ROS Bag files for writing and create the output directory.""" + + # Create output directory for written data + os.makedirs(Constants.DATA_COLLECTION_OUTPUT_DIR, exist_ok=True) + + # Initialize JSON and ROS bag files + if self.use_json: + self.__init_json_file() + if self.use_bag: + self.__init_ros_bag() + + def __init_json_file(self): + """Initializes a JSON file for data logging.""" + self.get_logger().debug("Initializing json file...") + + self.__data_to_write = {} + self.__json_index_counter = 0 + json_file_path = os.path.join( + Constants.DATA_COLLECTION_OUTPUT_DIR, self.file_name + ".json" + ) + + if os.path.exists(json_file_path): + self.get_logger().warn( + f"JSON file with name {self.file_name} already exists. Overwriting old file..." + ) + os.remove(json_file_path) + + # Open JSON file in append mode so continuous writes to end of file are possible + self.__json_file = open(json_file_path, "a") + + # Open JSON array with left bracket (should be closed with right bracket upon shutdown) + self.__json_file.write("[\n") + + # Initialize initial data to be None for each topic + for topic_name in self.__sub_topic_names.keys(): + self.__data_to_write[topic_name] = None + + def __init_ros_bag(self): + """Initializes ros bag for data logging. + https://docs.ros.org/en/humble/Tutorials/Advanced/Recording-A-Bag-From-Your-Own-Node-Py.html + """ + self.get_logger().debug("Initializing ros bag...") + + self.__writer = rosbag2_py.SequentialWriter() + file_path = os.path.join(Constants.DATA_COLLECTION_OUTPUT_DIR, self.file_name) + storage_options = rosbag2_py._storage.StorageOptions(uri=file_path, storage_id="sqlite3") + converter_options = rosbag2_py._storage.ConverterOptions("", "") + self.__writer.open(storage_options, converter_options) + + for topic_name, msg_type_name in self.__sub_topic_names.items(): + topic_info = rosbag2_py._storage.TopicMetadata( + name=topic_name, + type=msg_type_name, + serialization_format="cdr", + ) + self.__writer.create_topic(topic_info) + + def __init_timer_callbacks(self): + """Initializes timer callbacks of this node that are executed periodically.""" + self.get_logger().debug("Initializing timer callbacks...") + self.create_timer(timer_period_sec=self.json_write_period, callback=self.__write_to_json) + + def __init_shutdown_callbacks(self): + """Initializes shutdown callbacks of this node that are executed on shutdown.""" + self.get_logger().debug("Initializing shutdown callbacks...") + self.context.on_shutdown(self.__shutdown_callback) + + # SUBSCRIPTION CALLBACKS + def __general_sub_callback(self, msg: Type, topic_name: str): + """General subscription callback triggered when subscribed topics publish new data. For + JSON this function stores the message to later be written into the file by a timer + callback, and for ros bag this writes the message to the bag. + + Args: + msg (Type): The message published by the subscribed topic. + topic_name (str): The name of the topic that published the message. + """ + if self.use_json: + msg_as_ord_dict = rosidl_runtime_py.message_to_ordereddict(msg) + self.__data_to_write[topic_name] = msg_as_ord_dict + + if self.use_bag: + self.__writer.write( + topic_name, serialize_message(msg), self.get_clock().now().nanoseconds + ) + + # TIMER CALLBACKS + def __write_to_json(self): + """Write the most recent data to a JSON file if all subscribed topics have received at + least one message.""" + + # TODO: Handle the case where the subscribed topic is not launched to ensure data is + # written to JSON. + if not any(value is None for value in self.__data_to_write.values()): + # Recorded time is assumed to be evenly spaced by a specified period + self.__data_to_write["time"] = self.__json_index_counter * self.json_write_period + + # Create a Python dictionary and serialize it for writing + item_to_write = {self.__json_index_counter: self.__data_to_write} + json_string = json.dumps(item_to_write, indent=4) + + # The first entry should not have a prepended comma. All other entries should. + if self.__json_index_counter > 0: + json_string = ",\n" + json_string + + # Write the JSON string to the JSON file and increment the index counter for next write + self.__json_file.write(json_string) + self.__json_index_counter += 1 + + # SHUTDOWN CALLBACKS + def __shutdown_callback(self): + """Shutdown callback to close JSON file and ros bag.""" + self.get_logger().debug("Closing the storage files...") + + # Close the JSON array and then terminate I/O with the JSON file gracefully. + if self.use_json: + self.__json_file.write("\n]") + self.__json_file.close() + + # Stop ROS Bag I/O. Needs to be called after JSON close to prevent early exiting. + if self.use_bag: + self.__writer.close() + + @property + def file_name(self): + return self.get_parameter("file_name").get_parameter_value().string_value + + @property + def sub_topics(self): + return self.get_parameter("topics").get_parameter_value().string_array_value + + @property + def use_bag(self): + return self.get_parameter("bag").get_parameter_value().bool_value + + @property + def use_json(self): + return self.get_parameter("json").get_parameter_value().bool_value + + @property + def json_write_period(self): + return self.get_parameter("write_period_sec").get_parameter_value().double_value + + +if __name__ == "__main__": + main() diff --git a/src/boat_simulator/boat_simulator/nodes/low_level_control/__init__.py b/src/boat_simulator/boat_simulator/nodes/low_level_control/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/boat_simulator/nodes/low_level_control/control.py b/src/boat_simulator/boat_simulator/nodes/low_level_control/control.py new file mode 100644 index 000000000..d97534883 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/low_level_control/control.py @@ -0,0 +1,169 @@ +"""Low level control logic for actuating the rudder and the sail.""" + +from boat_simulator.common.types import Scalar +from typing import Any, List +from abc import ABC, abstractmethod + + +class PID(ABC): + + """Abstract class for a PID controller. + + Attributes: + `kp` (Scalar): The proportional component tuning constant. + `ki` (Scalar): The integral component tuning constant. + `kd` (Scalar): The derivative component tuning constant. + `time_period` (Scalar): Constant time period between error samples. + `buf_size` (int): The max number of error samples to store for integral component. + `error_timeseries` (List[Scalar]): Timeseries of error values computed over time. + """ + + # Private class member defaults + __kp: Scalar = 0 + __ki: Scalar = 0 + __kd: Scalar = 0 + __time_period: Scalar = 1 + __buf_size: int = 50 + __error_timeseries: List[Scalar] = list() + + def __init__(self, kp: Scalar, ki: Scalar, kd: Scalar, time_period: Scalar, buf_size: int): + """Initializes the class attributes. Note that this class cannot be directly instantiated. + + Args: + `kp` (Scalar): The proportional component tuning constant. + `ki` (Scalar): The integral component tuning constant. + `kd` (Scalar): The derivative component tuning constant. + `time_period` (Scalar): Time period between error samples. + `buf_size` (int): The max number of error samples to store for integral component. + """ + self.__kp = kp + self.__ki = ki + self.__kd = kd + self.__buf_size = buf_size + self.__time_period = time_period + self.__error_timeseries = list() + + def step(self, current: Any, target: Any) -> Scalar: + """Computes the correction factor. + + Args: + `current` (Any): Current state of the system. + `target` (Any): Target state of the system. + + Returns: + Scalar: Correction factor. + """ + raise NotImplementedError() + + def reset(self, is_latest_error_kept: bool = False) -> None: + """Empties the error timeseries of the PID controller, effectively starting a new + control iteration. + + Args: + is_latest_error_kept (bool, optional): True if the latest error is kept in the error + timeseries to avoid starting from scratch if the target remains the same. False + if the timeseries should be completely emptied. Defaults to False. + """ + raise NotImplementedError() + + def __append_error(self, error: Scalar) -> None: + """Appends the latest error to the error timeseries attribute. If the timeseries is at + the maximum buffer size, the least recently computed error is evicted from the timeseries + and the new one is appended. + + Args: + `error` (Scalar): The latest error. + """ + raise NotImplementedError() + + @abstractmethod + def _compute_error(self, current: Any, target: Any) -> Scalar: + """Computes the currently observed error. + + Args: + current (Any): Current state of the system. + target (Any): Target state of the system. + + Returns: + Scalar: Current error between the current and target states. + """ + pass + + @abstractmethod + def _compute_proportional_response(self) -> Scalar: + """ + Returns: + Scalar: The proportional component of the correction factor. + """ + pass + + @abstractmethod + def _compute_integral_response(self) -> Scalar: + """ + Returns: + Scalar: The integral component of the correction factor. + """ + pass + + @abstractmethod + def _compute_derivative_response(self) -> Scalar: + """ + Returns: + Scalar: The derivative component of the correction factor. + """ + pass + + @property + def kp(self) -> Scalar: + return self.__kp + + @property + def ki(self) -> Scalar: + return self.__ki + + @property + def kd(self) -> Scalar: + return self.__kd + + @property + def buf_size(self) -> Scalar: + return self.__buf_size + + @property + def time_period(self) -> Scalar: + return self.__time_period + + @property + def error_timeseries(self) -> List[Scalar]: + return self.__error_timeseries + + +class RudderPID(PID): + """Class for the rudder PID controller. + + Extends: PID + """ + + def __init__(self, kp: Scalar, ki: Scalar, kd: Scalar, time_period: Scalar, buf_size: int): + """Initializes the class attributes. + + Args: + `kp` (Scalar): The proportional component tuning constant. + `ki` (Scalar): The integral component tuning constant. + `kd` (Scalar): The derivative component tuning constant. + `time_period` (Scalar): Time period between error samples. + `buf_size` (int): The max number of error samples to store for integral component. + """ + super().__init__(kp, ki, kd, time_period, buf_size) + + def _compute_error(self, current: Scalar, target: Scalar) -> Scalar: + raise NotImplementedError() + + def _compute_proportional_response(self) -> Scalar: + raise NotImplementedError() + + def _compute_integral_response(self) -> Scalar: + raise NotImplementedError() + + def _compute_derivative_response(self) -> Scalar: + raise NotImplementedError() diff --git a/src/boat_simulator/boat_simulator/nodes/low_level_control/decorators.py b/src/boat_simulator/boat_simulator/nodes/low_level_control/decorators.py new file mode 100644 index 000000000..d6d98fa08 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/low_level_control/decorators.py @@ -0,0 +1,76 @@ +"""Decorator functions used in the low level control node.""" + +from typing import Callable + +from custom_interfaces.action import SimRudderActuation, SimSailTrimTabActuation + + +# TODO Devise a better method of making action server callacks mutually exclusive +class MutuallyExclusiveActionRoutine: + """A decorator that prevents multiple instances of an action server routine executing at once, + making it mutually exclusive. + + This decorator is only meant to be used inside the `LowLevelControlNode` class. + """ + + def __init__(self, action_type): + self.__action_type = action_type + + def __call__(self, func: Callable): + def check(obj, *args, **kwargs): + if self.__is_action_active(obj, func): + goal_handle = args[0] + return self.__cancel_goal_request(obj, goal_handle) + else: + return self.__execute_action_routine(obj, func, *args, **kwargs) + + return check + + def __is_action_active(self, obj, func: Callable): + if self.__action_type == SimRudderActuation: + return obj.is_rudder_action_active + elif self.__action_type == SimSailTrimTabActuation: + return obj.is_sail_action_active + else: + obj.get_logger().error( + f"Invalid action type {self.__action_type} for function {func.__name__}" + ) + return True + + def __cancel_goal_request(self, obj, goal_handle): + obj.get_logger().debug( + f"An action of type {self.__action_type} is already active. Cancelling goal request" + ) + goal_handle.abort() + + def __execute_action_routine(self, obj, func, *args, **kwargs): + self.__set_active_flag(obj) + + try: + result = func(obj, *args, **kwargs) + except RuntimeError: + obj.get_logger().error(f"An unexpected error occurred in {func.__name__}") + result = None + + self.__unset_active_flag(obj) + return result + + def __set_active_flag(self, obj): + if self.__action_type == SimRudderActuation: + obj._is_rudder_action_active = True + elif self.__action_type == SimSailTrimTabActuation: + obj._is_sail_action_active = True + else: + obj.get_logger().error( + f"Invalid action type {self.__action_type} while setting action active flag" + ) + + def __unset_active_flag(self, obj): + if self.__action_type == SimRudderActuation: + obj._is_rudder_action_active = False + elif self.__action_type == SimSailTrimTabActuation: + obj._is_sail_action_active = False + else: + obj.get_logger().error( + f"Invalid action type {self.__action_type} while unsetting action active flag" + ) diff --git a/src/boat_simulator/boat_simulator/nodes/low_level_control/low_level_control_node.py b/src/boat_simulator/boat_simulator/nodes/low_level_control/low_level_control_node.py new file mode 100644 index 000000000..ef676dcb3 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/low_level_control/low_level_control_node.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +"""The ROS node for the low level controller emulation.""" + +from typing import Optional + +import rclpy +import rclpy.utilities +from custom_interfaces.action import SimRudderActuation, SimSailTrimTabActuation +from custom_interfaces.action._sim_rudder_actuation import SimRudderActuation_Result +from custom_interfaces.action._sim_sail_trim_tab_actuation import ( + SimSailTrimTabActuation_Result, +) +from custom_interfaces.msg import GPS +from rclpy.action import ActionServer +from rclpy.action.server import ServerGoalHandle +from rclpy.executors import MultiThreadedExecutor +from rclpy.node import CallbackGroup, MutuallyExclusiveCallbackGroup, Node +from rclpy.subscription import Subscription +from rclpy.timer import Rate + +import boat_simulator.common.constants as Constants +from boat_simulator.common.types import Scalar +from boat_simulator.nodes.low_level_control.decorators import ( + MutuallyExclusiveActionRoutine, +) + + +def main(args=None): + rclpy.init(args=args) + node = LowLevelControlNode() + executor = MultiThreadedExecutor() + executor.add_node(node) + executor.spin() + rclpy.shutdown() + + +class LowLevelControlNode(Node): + """Performs the low-level contoller emulation for the rudder and sail actuation mechanisms. + + This class uses a multithreaded executor. Refrain from using any synchronous calls within + callbacks, or a deadlock is likely to occur. + + Subscriptions: + gps_sub (Subscription): Subscribes to a `GPS` message. + + Action Servers: + rudder_actuation_action_server (ActionServer): Performs the rudder actuation routine. + sail_actuation_action_server (ActionServer): Performs the sail trim tab actuation routine. + """ + + def __init__(self): + """Initializes an instance of this class.""" + super().__init__("low_level_control_node") + + self.get_logger().debug("Initializing node...") + self.__init_private_attributes() + self.__declare_ros_parameters() + self.__init_callback_groups() + self.__init_feedback_execution_rates() + self.__init_subscriptions() + self.__init_action_servers() + self.get_logger().debug("Node initialization complete. Starting execution...") + + def __init_private_attributes(self): + """Initializes private attributes of this class that are not initialized anywhere else + during the initialization process. + """ + self.__rudder_angle = 0 + self.__sail_trim_tab_angle = 0 + self._is_rudder_action_active = False + self._is_sail_action_active = False + self.__gps = None + + def __declare_ros_parameters(self): + """Declares ROS parameters from the global configuration file that will be used in this + node. This node will monitor for any changes to these parameters during execution and will + update itself accordingly. + """ + # TODO Update global YAML file with more configuration parameters and declare them here + self.get_logger().debug("Declaring ROS parameters...") + self.declare_parameters( + namespace="", + parameters=[ + ("pub_period_sec", rclpy.Parameter.Type.DOUBLE), + ("logging_throttle_period_sec", rclpy.Parameter.Type.DOUBLE), + ("info_log_throttle_period_sec", rclpy.Parameter.Type.DOUBLE), + ("qos_depth", rclpy.Parameter.Type.INTEGER), + ("rudder.disable_actuation", rclpy.Parameter.Type.BOOL), + ("rudder.fixed_angle_deg", rclpy.Parameter.Type.DOUBLE), + ("rudder.actuation_execution_period_sec", rclpy.Parameter.Type.DOUBLE), + ("rudder.pid.kp", rclpy.Parameter.Type.DOUBLE), + ("rudder.pid.ki", rclpy.Parameter.Type.DOUBLE), + ("rudder.pid.kd", rclpy.Parameter.Type.DOUBLE), + ("rudder.pid.buffer_size", rclpy.Parameter.Type.INTEGER), + ("wingsail.disable_actuation", rclpy.Parameter.Type.BOOL), + ("wingsail.fixed_angle_deg", rclpy.Parameter.Type.DOUBLE), + ("wingsail.actuation_execution_period_sec", rclpy.Parameter.Type.DOUBLE), + ("wingsail.actuation_speed_deg_per_sec", rclpy.Parameter.Type.DOUBLE), + ], + ) + + # TODO Revisit this debug statement. It might get ugly for args with complicated structures + all_parameters = self._parameters + for name, parameter in all_parameters.items(): + value_str = str(parameter.value) + self.get_logger().debug(f"Got parameter {name} with value {value_str}") + + def __init_callback_groups(self): + """Initializes the callback groups. This node uses a multithreaded executor, so multiple + callback groups are used to parallelize multiple action servers in the same node. + + Callbacks belonging to different groups may execute in parallel to each other. Learn more + about executors and callback groups here: + https://docs.ros.org/en/humble/Concepts/Intermediate/About-Executors.html#executors + """ + self.get_logger().debug("Initializing callback groups...") + self.__pub_sub_callback_group = MutuallyExclusiveCallbackGroup() + self.__rudder_action_callback_group = MutuallyExclusiveCallbackGroup() + self.__sail_action_callback_group = MutuallyExclusiveCallbackGroup() + + def __init_feedback_execution_rates(self): + """Initializes rate objects used in this node to control how often a loop is executed + within a callback. + + WARNING: Care should be taken when using rate objects to sleep within a callback. If using + a single threaded executor, sleeping in a callback may block execution forever, causing a + deadlock. Only sleep if the node executor is multithreaded. + """ + self.get_logger().debug("Initializing rate objects...") + self.__rudder_action_feedback_rate = self.create_rate( + frequency=self.get_parameter("rudder.actuation_execution_period_sec") + .get_parameter_value() + .double_value, + clock=self.get_clock(), + ) + self.__sail_action_feedback_rate = self.create_rate( + frequency=self.get_parameter("wingsail.actuation_execution_period_sec") + .get_parameter_value() + .double_value, + clock=self.get_clock(), + ) + + def __init_subscriptions(self): + """Initializes the subscriptions of this node. Subscriptions pull data from other ROS + topics for further usage in this node. Data is pulled from subscriptions periodically via + callbacks, which are registered upon subscription initialization. + """ + self.get_logger().debug("Initializing subscriptions...") + self.__gps_sub = self.create_subscription( + msg_type=GPS, + topic=Constants.LOW_LEVEL_CTRL_SUBSCRIPTIONS.GPS, + callback=self.__gps_sub_callback, + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + callback_group=self.pub_sub_callback_group, + ) + + def __init_action_servers(self): + """Initializes the action servers of this node. Action servers perform a specified routine + when a goal request comes from an action client. + """ + self.get_logger().debug("Initializing action servers...") + self.__rudder_actuation_action_server = ActionServer( + node=self, + action_type=SimRudderActuation, + action_name=Constants.ACTION_NAMES.RUDDER_ACTUATION, + execute_callback=self.__rudder_actuation_routine, + callback_group=self.rudder_action_callback_group, + ) + self.__sail_actuation_action_server = ActionServer( + node=self, + action_type=SimSailTrimTabActuation, + action_name=Constants.ACTION_NAMES.SAIL_ACTUATION, + execute_callback=self.__sail_actuation_routine, + callback_group=self.sail_action_callback_group, + ) + + def __gps_sub_callback(self, msg: GPS): + """Stores the latest GPS data. + + Args: + msg (GPS): The GPS data from the physics engine. + """ + self.get_logger().info( + f"Received data from {self.gps_sub.topic}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + self.__gps = msg + + @MutuallyExclusiveActionRoutine(action_type=SimRudderActuation) + def __rudder_actuation_routine( + self, goal_handle: ServerGoalHandle + ) -> Optional[SimRudderActuation_Result]: + """The rudder actuation action server routine. Given a desired heading as the goal, the + rudder is actuated until the desired heading is reached or the action times out. + + Args: + goal_handle (ServerGoalHandle): The server goal specified by the client. + + Returns: + Optional[SimRudderActuation_Result]: The result message if successful. + """ + self.get_logger().debug("Beginning rudder actuation...") + + # TODO Placeholder loop. Replace with PID ctrl once implemented. + feedback_msg = SimRudderActuation.Feedback() + for i in range(Constants.RUDDER_ACTUATION_NUM_LOOP_EXECUTIONS): + feedback_msg.rudder_angle = float(i) + self.get_logger().debug(f"Rudder Action Server feedback: {i}") + self.get_logger().debug(f"Is Rudder Action Active? {self.is_rudder_action_active}") + goal_handle.publish_feedback(feedback=feedback_msg) + self.rudder_action_feedback_rate.sleep() + + goal_handle.succeed() + + result = SimRudderActuation.Result() + result.remaining_angular_distance = 0.0 + return result + + @MutuallyExclusiveActionRoutine(action_type=SimSailTrimTabActuation) + def __sail_actuation_routine( + self, goal_handle: ServerGoalHandle + ) -> Optional[SimSailTrimTabActuation_Result]: + """The sail actuation action server routine. Given a desired angular position as the goal, + the trim tab is actuated until the desired position is reached or the action times out. + + Args: + goal_handle (ServerGoalHandle): The server goal specified by the client. + + Returns: + Optional[SimSailTrimTabActuation_Result]: The result message if successful. + """ + self.get_logger().debug("Beginning sail actuation...") + + # TODO Placeholder loop. Replace with sail ctrl once implemented. + feedback_msg = SimSailTrimTabActuation.Feedback() + for i in range(Constants.SAIL_ACTUATION_NUM_LOOP_EXECUTIONS): + feedback_msg.current_angular_position = float(i) + self.get_logger().debug(f"Sail Action Server feedback: {i}") + self.get_logger().debug(f"Is Sail Action Active? {self.is_sail_action_active}") + goal_handle.publish_feedback(feedback=feedback_msg) + self.sail_action_feedback_rate.sleep() + + goal_handle.succeed() + + result = SimSailTrimTabActuation.Result() + result.remaining_angular_distance = 0.0 + return result + + @property + def is_multithreading_enabled(self) -> bool: + return self.__is_multithreading_enabled + + @property + def pub_sub_callback_group(self) -> CallbackGroup: + return self.__pub_sub_callback_group + + @property + def rudder_action_callback_group(self) -> CallbackGroup: + return self.__rudder_action_callback_group + + @property + def sail_action_callback_group(self) -> CallbackGroup: + return self.__sail_action_callback_group + + @property + def rudder_actuation_action_server(self) -> ActionServer: + return self.__rudder_actuation_action_server + + @property + def sail_actuation_action_server(self) -> ActionServer: + return self.__sail_actuation_action_server + + @property + def rudder_action_feedback_rate(self) -> Rate: + return self.__rudder_action_feedback_rate + + @property + def sail_action_feedback_rate(self) -> Rate: + return self.__sail_action_feedback_rate + + @property + def is_rudder_action_active(self) -> bool: + return self._is_rudder_action_active + + @property + def is_sail_action_active(self) -> bool: + return self._is_sail_action_active + + @property + def pub_period(self) -> float: + return self.get_parameter("pub_period_sec").get_parameter_value().double_value + + @property + def gps(self) -> Optional[GPS]: + return self.__gps + + @property + def gps_sub(self) -> Subscription: + return self.__gps_sub + + @property + def rudder_angle(self) -> Scalar: + return self.__rudder_angle + + @property + def sail_trim_tab_angle(self) -> Scalar: + return self.__sail_trim_tab_angle + + +if __name__ == "__main__": + main() diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/__init__.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/decorators.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/decorators.py new file mode 100644 index 000000000..36568636a --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/decorators.py @@ -0,0 +1,31 @@ +"""Decorator functions used in the physics engine.""" + +from typing import Callable + + +# TODO Pull all subscriptions at once rather than one at a time to avoid needing to update in the +# future if more subscriptions are added +def require_all_subs_active(func: Callable): + """A decorator that asserts all subscriptions must be active in a node in order for a the + wrapped function to be executed. This decorator is only meant to be used inside the + `PhysicsEngineNode` class. + + Args: + func (Callable): The wrapped function. + """ + + def is_all_subs_active(obj) -> bool: + is_desired_heading_valid = obj.desired_heading is not None + obj.get_logger().debug( + f"Is `desired_heading` subscription valid? {is_desired_heading_valid}" + ) + return is_desired_heading_valid + + def check(obj, *args, **kwargs): + if is_all_subs_active(obj): + return func(obj, *args, **kwargs) + else: + obj.get_logger().warn(f"All subscribers must be active to invoke {func.__name__}") + return + + return check diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py new file mode 100644 index 000000000..c479bdb2d --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py @@ -0,0 +1,93 @@ +"""This module provides functionality for computing the lift and drag forces acting on a medium.""" + +from typing import Tuple + +from numpy.typing import NDArray + +from boat_simulator.common.types import Scalar + + +class MediumForceComputation: + """This class calculates the lift and drag forces experienced by a medium when subjected to + fluid flow. + + Attributes: + `lift_coefficients` (NDArray): An array of shape (n, 2) where each row contains a pair + (x, y) representing an angle of attack, in degrees, and its corresponding lift + coefficient. + `drag_coefficients` (NDArray): An array of shape (n, 2) where each row contains a pair + (x, y) representing an angle of attack, in degrees, and its corresponding drag + coefficient. + `areas` (NDArray): An array of shape (n, 2) where each row contains a pair (x, y), + representing an angle of attack, in degrees, and its corresponding area, in square + meters (m^2). + `fluid_density` (Scalar): The density of the fluid acting on the medium, expressed in + kilograms per cubic meter (kg/m^3). + """ + + def __init__( + self, + lift_coefficients: NDArray, + drag_coefficients: NDArray, + areas: NDArray, + fluid_density: Scalar, + ): + self.__lift_coefficients = lift_coefficients + self.__drag_coefficients = drag_coefficients + self.__areas = areas + self.__fluid_density = fluid_density + + def compute(self, apparent_velocity: NDArray, orientation: Scalar) -> Tuple[NDArray, NDArray]: + """Computes the lift and drag forces experienced by a medium immersed in a fluid. + + Args: + apparent_velocity (NDArray): The apparent (relative) velocity between the fluid and the + medium, calculated as the difference between the fluid velocity and the medium + velocity (fluid_velocity - medium_velocity), expressed in meters per second (m/s). + orientation (Scalar): The orientation angle of the medium in degrees, where 0 degrees + corresponds to the positive x-axis, and angles increase counter-clockwise (CCW). + + Returns: + Tuple[NDArray, NDArray]: A tuple containing the lift force and drag force experienced + by the medium, both expressed in newtons (N). + """ + + # TODO: Implement this method. + + raise NotImplementedError() + + def interpolate(self, attack_angle: Scalar) -> Tuple[Scalar, Scalar, Scalar]: + """Performs linear interpolation to estimate the lift and drag coefficients, as well as the + associated area upon which the fluid acts, based on the provided angle of attack. + + Args: + attack_angle (Scalar): The angle of attack formed between the orientation angle of + the medium and the direction of the apparent velocity, expressed in degrees. + + Returns: + Tuple[Scalar, Scalar, Scalar]: A tuple representing the computed parameters. The + first scalar denotes the lift coefficient, the second scalar represents the + drag coefficient, and the third scalar indicates the surface area upon which + the fluid acts. Both lift and drag coefficients are unitless, while the + area is expressed in square meters (m^2). + """ + + # TODO: Implement this method using `np.interp`. + + raise NotImplementedError() + + @property + def lift_coefficients(self) -> NDArray: + return self.__lift_coefficients + + @property + def drag_coefficients(self) -> NDArray: + return self.__drag_coefficients + + @property + def areas(self) -> NDArray: + return self.__areas + + @property + def fluid_density(self) -> Scalar: + return self.__fluid_density diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_generation.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_generation.py new file mode 100644 index 000000000..8c544ee02 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_generation.py @@ -0,0 +1,69 @@ +"""This module provides a generator for fluid vectors used within the physics engine.""" + +import numpy as np +from numpy.typing import NDArray + +from boat_simulator.common.generators import VectorGenerator +from boat_simulator.common.types import Scalar + + +class FluidGenerator: + """This class provides functionality to generate velocity vectors representing fluid movements. + + Attributes: + `generator` (VectorGenerator): The vector generator used to generate fluid velocities. + `velocity` (NDArray): The most recently generated fluid velocity vector, expressed in + meters per second (m/s). + """ + + def __init__(self, generator: VectorGenerator): + self.__generator = generator + self.__velocity = np.array(self.__generator.next()) + + def next(self) -> NDArray: + """Generates the next velocity vector for the fluid simulation. + + Returns: + NDArray: An array representing the updated velocity vector for the fluid simulation. + """ + + # TODO: Implement this to generate the next velocity vector. + + raise NotImplementedError() + + @property + def velocity(self) -> NDArray: + """Returns the fluid's current velocity vector. + + Returns: + NDArray: The velocity vector of the fluid, expressed in meters per second (m/s) and + ranging from negative infinity to positive infinity. + """ + + raise NotImplementedError() + + @property + def speed(self) -> Scalar: + """Calculates the current speed of the fluid. + + Returns: + Scalar: The speed of the fluid, expressed in meters per second (m/s) and within the + range of 0 to positive infinity. + """ + + # TODO: Implement this using the current velocity vector. + + raise NotImplementedError() + + @property + def direction(self) -> Scalar: + """Calculates the current direction of the fluid. + + Returns: + Scalar: The direction of the fluid, expressed in degrees and bounded between + [-180, 180). + """ + + # TODO: Implement this using the current velocity vector. + + raise NotImplementedError() diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_computation.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_computation.py new file mode 100644 index 000000000..59691efc0 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_computation.py @@ -0,0 +1,183 @@ +"""This module contains the kinematics computations for the boat.""" + +from typing import Tuple + +import numpy as np +from numpy.typing import NDArray + +import boat_simulator.common.constants as constants +import boat_simulator.common.utils as utils +from boat_simulator.common.types import Scalar +from boat_simulator.nodes.physics_engine.kinematics_data import KinematicsData +from boat_simulator.nodes.physics_engine.kinematics_formulas import KinematicsFormulas + + +class BoatKinematics: + """Computes and stores kinematic data of the boat at different time steps. + + Attributes: + `timestep` (Scalar): The time interval for calculations, expressed in seconds (s). + `boat_mass` (Scalar): The mass of the boat, expressed in kilograms (kg). + `inertia` (NDArray): The inertia of the boat, expressed in kilograms-meters squared + (kg•m^2). + `inertia_inverse` (NDArray): The inverse of the inertia matrix, expressed in + kilograms-meters squared (kg•m^2). + `relative_data` (KinematicsData): Kinematics data in the relative reference frame, using + SI units. + `global_data` (KinematicsData): Kinematics data in the global reference frame, using SI + units. + """ + + def __init__(self, timestep: Scalar, mass: Scalar, inertia: NDArray) -> None: + """Initializes an instance of `BoatKinematics`. + + Args: + timestep (Scalar): The time interval for calculations, expressed in seconds (s). + mass (Scalar): The mass of the boat, expressed in kilograms (kg). + inertia (NDArray): The inertia of the boat, expressed in kilograms-meters squared + (kg•m^2). + """ + self.__timestep = timestep + self.__boat_mass = mass + assert inertia.shape == (3, 3) + self.__inertia = inertia + self.__inertia_inverse = np.linalg.inv(inertia) + self.__relative_data = KinematicsData() + self.__global_data = KinematicsData() + + def step( + self, rel_net_force: NDArray, net_torque: NDArray + ) -> Tuple[KinematicsData, KinematicsData]: + """Updates the kinematic data based on applied forces and torques. + + Args: + rel_net_force (NDArray): The net force acting on the boat in the relative frame, + expressed in newtons (N). + net_torque (NDArray): The net torque acting on the boat, expressed in newton-meters + (N•m). + + Returns: + Tuple[KinematicsData, KinematicsData]: A tuple containing updated kinematic data. The + first element represents data in the relative reference frame, and the second + element represents data in the global reference frame, both using SI units. + """ + yaw_radians = self.__update_ang_data(net_torque) + + self.__update_linear_relative_data(rel_net_force) + + # z-directional acceleration and velocity are neglected + glo_net_force = rel_net_force * np.array([np.cos(yaw_radians), np.sin(yaw_radians), 0]) + self.__update_linear_global_data(glo_net_force) + + return (self.relative_data, self.global_data) + + def __update_ang_data(self, net_torque: NDArray) -> Scalar: + """Update the angular kinematics data. + + Args: + net_torque (NDArray): The net torque acting on the boat, expressed in newton-meters + (N•m). + + Returns: + Scalar: The next angular position along the yaw axis in the global reference frame, + expressed in radians (rad). + """ + next_ang_acceleration = KinematicsFormulas.next_ang_acceleration( + net_torque, self.inertia_inverse + ) + + next_ang_velocity = KinematicsFormulas.next_velocity( + self.global_data.angular_velocity, + self.global_data.angular_acceleration, + self.timestep, + ) + + next_ang_position = utils.bound_to_180( + KinematicsFormulas.next_position( + self.global_data.angular_position, + self.global_data.angular_velocity, + self.global_data.angular_acceleration, + self.timestep, + ), + isDegrees=False, + ) + + self.__relative_data.angular_acceleration = next_ang_acceleration + self.__relative_data.angular_velocity = next_ang_velocity + self.__relative_data.angular_position[:] = 0 # relative angular position is unused + + self.__global_data.angular_acceleration = next_ang_acceleration + self.__global_data.angular_velocity = next_ang_velocity + self.__global_data.angular_position = next_ang_position + + yaw_radians = next_ang_position[constants.ORIENTATION_INDICES.YAW.value] + + return yaw_radians + + def __update_linear_relative_data(self, net_force: NDArray) -> None: + """Updates the linear kinematic data in the relative reference frame. + + Args: + net_force (NDArray): The net force acting on the boat in the relative reference + frame, expressed in newtons (N). + """ + next_relative_acceleration = KinematicsFormulas.next_lin_acceleration( + self.boat_mass, net_force + ) + next_relative_velocity = KinematicsFormulas.next_velocity( + self.relative_data.linear_velocity, + self.relative_data.linear_acceleration, + self.timestep, + ) + + self.__relative_data.linear_acceleration = next_relative_acceleration + self.__relative_data.linear_velocity = next_relative_velocity + self.__relative_data.linear_position[:] = 0 # linear position is unused + + def __update_linear_global_data(self, net_force: NDArray) -> None: + """Updates the linear kinematic data in the global reference frame. + + Args: + net_force (NDArray): The net force acting on the boat in the global reference frame, + expressed in newtons (N). + """ + next_global_acceleration = KinematicsFormulas.next_lin_acceleration( + self.boat_mass, net_force + ) + next_global_velocity = KinematicsFormulas.next_velocity( + self.global_data.linear_velocity, self.global_data.linear_acceleration, self.timestep + ) + next_global_position = KinematicsFormulas.next_position( + self.global_data.linear_position, + self.global_data.linear_velocity, + self.global_data.linear_acceleration, + self.timestep, + ) + + self.__global_data.linear_acceleration = next_global_acceleration + self.__global_data.linear_velocity = next_global_velocity + self.__global_data.linear_position = next_global_position + + @property + def relative_data(self) -> KinematicsData: + return self.__relative_data + + @property + def global_data(self) -> KinematicsData: + return self.__global_data + + @property + def timestep(self) -> Scalar: + return self.__timestep + + @property + def inertia(self) -> NDArray: + return self.__inertia + + @property + def inertia_inverse(self) -> NDArray: + return self.__inertia_inverse + + @property + def boat_mass(self) -> Scalar: + return self.__boat_mass diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_data.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_data.py new file mode 100644 index 000000000..ee1dc6d83 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_data.py @@ -0,0 +1,33 @@ +"""This module contains the kinematics data for the boat.""" + +from dataclasses import dataclass, field + +import numpy as np +from numpy.typing import NDArray + + +@dataclass +class KinematicsData: + """Stores both linear and angular kinematic information pertaining to the boat. + + Attributes: + `linear_position` (NDArray): Linear position of the boat, expressed in meters (m). + `linear_velocity` (NDArray): Linear velocity of the boat, expressed in meters per + second (m/s). + `linear_acceleration` (NDArray): Linear acceleration of the boat, expressed in + meters per second squared (m/s^2). + `angular_position` (NDArray): Angular position of the boat, expressed in radians + (rad). + `angular_velocity` (NDArray): Angular velocity of the boat, expressed in radians + per second (rad/s). + `angular_acceleration` (NDArray): Angular acceleration of the boat, expressed in + radians per second squared (rad/s^2). + """ + + # TODO: Ensure position is always set to 0 for relative reference frame + linear_position: NDArray = field(default=np.zeros(3, dtype=np.float32)) + linear_velocity: NDArray = field(default=np.zeros(3, dtype=np.float32)) + linear_acceleration: NDArray = field(default=np.zeros(3, dtype=np.float32)) + angular_position: NDArray = field(default=np.zeros(3, dtype=np.float32)) + angular_velocity: NDArray = field(default=np.zeros(3, dtype=np.float32)) + angular_acceleration: NDArray = field(default=np.zeros(3, dtype=np.float32)) diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_formulas.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_formulas.py new file mode 100644 index 000000000..095e8c74c --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/kinematics_formulas.py @@ -0,0 +1,85 @@ +"""Formulas for estimating the next position, velocity, and acceleration.""" + +from numpy.typing import NDArray + +from boat_simulator.common.types import Scalar + + +class KinematicsFormulas: + """Contains formulas for calculating the boat's next position, velocity, and acceleration based + on previous kinematic data.""" + + @staticmethod + def next_position(pos: NDArray, vel: NDArray, acc: NDArray, timestep: Scalar) -> NDArray: + """Calculates the boat's next position based on previous data and time step. Can be used + for both linear and angular positions. + + Args: + pos (NDArray): The last recorded boat position prior to the current time step, + expressed in meters (m) for linear, and radians (rad) for angular. + vel (NDArray): The last recorded boat velocity prior to the current time step, + expressed in meter per second (m/s) for linear, and radians per second (rad/s) for + angular. + acc (NDArray): The last recorded boat acceleration prior to the current time step, + expressed in meters per second squared (m/s^2) for linear, and radians per second + squared (rad/s^2) for angular. + timestep (Scalar): The time interval on which the calculation is based, expressed in + seconds (s). + + Returns: + NDArray: The calculated next position of the boat, expressed in meters (m) for linear, + and radians (rad) for angular. + """ + return pos + (vel * timestep) + (acc * (timestep**2 / 2)) + + @staticmethod + def next_velocity(vel: NDArray, acc: NDArray, timestep: Scalar) -> NDArray: + """Calculates the boat's next velocity based on previous velocity, acceleration, and time + step. Can be used for both linear and angular velocities. + + Args: + vel (NDArray): The last recorded boat velocity prior to the current time step, + expressed in meters per second (m/s) for linear, and radians per second (rad/s) for + angular. + acc (NDArray): The last recorded boat acceleration prior to the current time step, + expressed in meters per second squared (m/s^2) for linear, and radians per second + squared (rad/s^2) for angular. + timestep (Scalar): The time interval on which the calculation is based, expressed in + seconds (s). + + Returns: + NDArray: The calculated next velocity of the boat, expressed in meters per second (m/s) + for linear, and radians per second (rad/s) for angular. + """ + return vel + (acc * timestep) + + @staticmethod + def next_lin_acceleration(mass: Scalar, net_force: NDArray) -> NDArray: + """Calculates the boat's next linear acceleration based on its mass and net force. + + Args: + mass (float): The mass of the boat, expressed in kilograms (kg). + net_force (NDArray): The net force acting on the boat, expressed in newtons (N). + + Returns: + NDArray: The calculated next linear acceleration of the boat, expressed in meters per + second squared (m/s^2). + """ + return net_force / mass + + @staticmethod + def next_ang_acceleration(net_torque: NDArray, inertia_inverse: NDArray) -> NDArray: + """Calculates the boat's next angular acceleration based on net torque and inverse of + inertia. + + Args: + net_torque (NDArray): The net torque acting on the boat, expressed in newton-meters + (N•m). + inertia_inverse (NDArray): The inverse of the boat's inertia, expressed in + kilograms-meters squared (kg•m^2). + + Returns: + NDArray: The calculated next angular acceleration of the boat, expressed in radians per + second squared (rad/s^2). + """ + return inertia_inverse @ net_torque diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/model.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/model.py new file mode 100644 index 000000000..3f7c11fc8 --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/model.py @@ -0,0 +1,121 @@ +"""This module represents the state of the boat at a given step in time.""" + +from typing import Tuple + +import numpy as np +from numpy.typing import NDArray + +from boat_simulator.common.types import Scalar +from boat_simulator.nodes.physics_engine.kinematics_computation import BoatKinematics +from boat_simulator.nodes.physics_engine.kinematics_data import KinematicsData + + +class BoatState: + """Represents the state of the boat at a specific point in time, including kinematic data + in both relative and global reference frames. + + Attributes: + `kinematics_computation` (BoatKinematics): The kinematic data for the boat in both + the relative and global reference frames, used for computing future kinematic data, + expressed in SI units. + """ + + def __init__(self, timestep: Scalar, mass: Scalar, inertia: NDArray): + """Initializes an instance of `BoatState`. + + Args: + timestep (Scalar): The time interval for calculations, expressed in seconds (s). + mass (Scalar): The mass of the boat, expressed in kilograms (kg). + inertia (NDArray): The inertia of the boat, expressed in kilograms-meters squared + (kg•m^2). + """ + self.__kinematics_computation = BoatKinematics(timestep, mass, inertia) + + def compute_net_force_and_torque(self, wind_vel: NDArray) -> Tuple[NDArray, NDArray]: + """Calculates the net force and net torque acting on the boat due to the wind. + + Args: + wind_vel (NDArray): The velocity of the wind, expressed in meters per second (m/s). + + Returns: + Tuple[NDArray, NDArray]: A tuple where the first element represents the net force in + the relative reference frame, expressed in newtons (N), and the second element + represents the net torque, expressed in newton-meters (N•m). + """ + raise NotImplementedError() + + def step( + self, rel_net_force: NDArray, net_torque: NDArray + ) -> Tuple[KinematicsData, KinematicsData]: + """Updates the boat's kinematic data based on applied forces and torques, and returns + the updated kinematic data in both relative and global reference frames. + + Args: + rel_net_force (NDArray): The net force acting on the boat in the relative reference + frame, expressed in newtons (N). + net_torque (NDArray): The net torque acting on the boat, expressed in newton-meters + (N•m). + + Returns: + Tuple[KinematicsData, KinematicsData]: A tuple with the first element representing + kinematic data in the relative reference frame, and the second element representing + data in the global reference frame, both using SI units. + """ + return self.__kinematics_computation.step(rel_net_force, net_torque) + + @property + def global_position(self) -> NDArray: + return self.__kinematics_computation.global_data.linear_position + + @property + def global_velocity(self) -> NDArray: + return self.__kinematics_computation.global_data.linear_velocity + + @property + def global_acceleration(self) -> NDArray: + return self.__kinematics_computation.global_data.linear_acceleration + + @property + def relative_velocity(self) -> NDArray: + return self.__kinematics_computation.relative_data.linear_velocity + + @property + def relative_acceleration(self) -> NDArray: + return self.__kinematics_computation.relative_data.linear_acceleration + + @property + def angular_position(self) -> NDArray: + return self.__kinematics_computation.relative_data.angular_position + + @property + def angular_velocity(self) -> NDArray: + return self.__kinematics_computation.relative_data.angular_velocity + + @property + def angular_acceleration(self) -> NDArray: + return self.__kinematics_computation.relative_data.angular_position + + @property + def inertia(self) -> NDArray: + return self.__kinematics_computation.inertia + + @property + def inertia_inverse(self) -> NDArray: + return self.__kinematics_computation.inertia_inverse + + @property + def boat_mass(self) -> Scalar: + return self.__kinematics_computation.boat_mass + + @property + def timestep(self) -> Scalar: + return self.__kinematics_computation.timestep + + @property + def speed(self) -> Scalar: + return np.linalg.norm(x=self.relative_velocity, ord=2) + + @property + def true_bearing(self) -> Scalar: + # TODO: Implement this function + return 0 diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/output_interface.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/output_interface.py new file mode 100644 index 000000000..b98e5bcef --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/output_interface.py @@ -0,0 +1,13 @@ +"""Logic that adjusts output data to reflect the real world as much as possible.""" + + +class OutputNoise: + pass + + +class OutputDelay: + pass + + +class OutputInterface: + pass diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/physics_engine_node.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/physics_engine_node.py new file mode 100644 index 000000000..3401b512e --- /dev/null +++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/physics_engine_node.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 + +"""The ROS node for the physics engine.""" + +import sys +from typing import Optional + +import rclpy +import rclpy.utilities +from custom_interfaces.action import SimRudderActuation, SimSailTrimTabActuation +from custom_interfaces.action._sim_rudder_actuation import ( + SimRudderActuation_FeedbackMessage, +) +from custom_interfaces.action._sim_sail_trim_tab_actuation import ( + SimSailTrimTabActuation_FeedbackMessage, +) +from custom_interfaces.msg import ( + GPS, + DesiredHeading, + SailCmd, + SimWorldState, + WindSensor, + WindSensors, +) +from rclpy.action import ActionClient +from rclpy.action.client import ClientGoalHandle, Future +from rclpy.executors import Executor, MultiThreadedExecutor, SingleThreadedExecutor +from rclpy.node import CallbackGroup, MutuallyExclusiveCallbackGroup, Node +from rclpy.publisher import Publisher +from rclpy.subscription import Subscription + +import boat_simulator.common.constants as Constants +from boat_simulator.common.types import Scalar + +from .decorators import require_all_subs_active + + +def main(args=None): + rclpy.init(args=args) + multithreading_enabled = is_multithreading_enabled() + node = PhysicsEngineNode(multithreading_enabled=multithreading_enabled) + executor = get_executor(is_multithreading_enabled=multithreading_enabled) + executor.add_node(node) + executor.spin() + rclpy.shutdown() + + +def is_multithreading_enabled() -> bool: + try: + is_multithreading_enabled_index = sys.argv.index(Constants.MULTITHREADING_CLI_ARG_NAME) + 1 + is_multithreading_enabled = sys.argv[is_multithreading_enabled_index] == "true" + except ValueError: + is_multithreading_enabled = False + return is_multithreading_enabled + + +def get_executor(is_multithreading_enabled: bool) -> Executor: + if is_multithreading_enabled: + return MultiThreadedExecutor() + else: + return SingleThreadedExecutor() + + +class PhysicsEngineNode(Node): + """Stores, updates, and maintains the state of the physics model of the boat simulator. + + Subscriptions: + desired_heading_sub (Subscription): Subscribes to a `DesiredHeading` message. + + Publishers: + gps_pub (Publisher): Publishes GPS data in a `GPS` message. + wind_sensors_pub (Publisher): Publishes wind sensor data in a `WindSensors` message. + kinematics_pub (Publisher): Publishes kinematics data in a `SimWorldState` message. + + Action Clients: + rudder_actuation_action_client (ActionClient): Requests rudder actuations. + sail_actuation_action_client (ActionClient): Requests sail trim tab actuations. + """ + + def __init__(self, multithreading_enabled: bool): + """Initializes an instance of this class. + + Args: + multithreading_enabled (bool): True if this node uses a multithreaded executor, and + false for a single threaded executor. + """ + super().__init__(node_name="physics_engine_node") + self.__is_multithreading_enabled = multithreading_enabled + + self.get_logger().debug("Initializing node...") + self.__init_private_attributes() + self.__declare_ros_parameters() + self.__init_callback_groups() + self.__init_subscriptions() + self.__init_publishers() + self.__init_action_clients() + self.__init_timer_callbacks() + self.get_logger().debug("Node initialization complete. Starting execution...") + + # INITIALIZATION HELPERS + def __init_private_attributes(self): + """Initializes the private attributes of this class that are not set anywhere else during + the initialization process. + """ + self.__publish_counter = 0 + self.__rudder_angle = 0 + self.__sail_trim_tab_angle = 0 + self.__desired_heading = None + + def __declare_ros_parameters(self): + """Declares ROS parameters from the global configuration file that will be used in this + node. This node will monitor for any changes to these parameters during execution and will + update itself accordingly. + """ + # TODO Update global YAML file with more configuration parameters and declare them here + self.get_logger().debug("Declaring ROS parameters...") + self.declare_parameters( + namespace="", + parameters=[ + ("pub_period_sec", rclpy.Parameter.Type.DOUBLE), + ("logging_throttle_period_sec", rclpy.Parameter.Type.DOUBLE), + ("info_log_throttle_period_sec", rclpy.Parameter.Type.DOUBLE), + ("action_send_goal_timeout_sec", rclpy.Parameter.Type.DOUBLE), + ("qos_depth", rclpy.Parameter.Type.INTEGER), + ("rudder.actuation_request_period_sec", rclpy.Parameter.Type.DOUBLE), + ("wingsail.actuation_request_period_sec", rclpy.Parameter.Type.DOUBLE), + ("wind_sensor.generator_type", rclpy.Parameter.Type.STRING), + ("wind_sensor.gaussian_params.mean", rclpy.Parameter.Type.DOUBLE_ARRAY), + ("wind_sensor.gaussian_params.std_dev", rclpy.Parameter.Type.DOUBLE_ARRAY), + ("wind_sensor.gaussian_params.corr_xy", rclpy.Parameter.Type.DOUBLE), + ("wind_sensor.constant_params.value", rclpy.Parameter.Type.DOUBLE_ARRAY), + ], + ) + + # TODO Revisit this debug statement. It might get ugly for args with complicated structures + all_parameters = self._parameters + for name, parameter in all_parameters.items(): + value_str = str(parameter.value) + self.get_logger().debug(f"Got parameter {name} with value {value_str}") + + def __init_callback_groups(self): + """Initializes the callback groups. Whether multithreading is enabled or not will affect + how callbacks are executed. + + If multithreading is enabled: Callbacks belonging to different callback groups may execute + in parallel to each other. + + If multithreading is disabled: All callbacks are assigned to the same default callback + group, and only one callback may execute at a time in a single-threaded manner + (the ROS2 default). + + Learn more about executors and callback groups here: + https://docs.ros.org/en/humble/Concepts/Intermediate/About-Executors.html#executors + """ + # TODO Consider if data to each topic should be published in parallel or synchronously + if self.is_multithreading_enabled: + self.get_logger().debug( + "Multithreading enabled. Initializing multiple callback groups" + ) + self.__pub_callback_group = MutuallyExclusiveCallbackGroup() + self.__sub_callback_group = MutuallyExclusiveCallbackGroup() + self.__rudder_action_callback_group = MutuallyExclusiveCallbackGroup() + self.__sail_action_callback_group = MutuallyExclusiveCallbackGroup() + else: + self.get_logger().debug( + "Multithreading disabled. Assigning all callbacks to the default callback group" + ) + self.__pub_callback_group = self.default_callback_group + self.__sub_callback_group = self.default_callback_group + self.__rudder_action_callback_group = self.default_callback_group + self.__sail_action_callback_group = self.default_callback_group + + def __init_subscriptions(self): + """Initializes the subscriptions of this node. Subscriptions pull data from other ROS + topics for further usage in this node. Data is pulled from subscriptions periodically via + callbacks, which are registered upon subscription initialization. + """ + # TODO Subscribe to CAN/Sim interface output to replace the current subscriptions + self.get_logger().debug("Initializing subscriptions...") + self.__desired_heading_sub = self.create_subscription( + msg_type=DesiredHeading, + topic=Constants.PHYSICS_ENGINE_SUBSCRIPTIONS.DESIRED_HEADING, + callback=self.__desired_heading_sub_callback, + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + callback_group=self.sub_callback_group, + ) + self.__sail_trim_tab_angle_sub = self.create_subscription( + msg_type=SailCmd, + topic=Constants.PHYSICS_ENGINE_SUBSCRIPTIONS.SAIL_TRIM_TAB_ANGLE, + callback=self.__sail_trim_tab_angle_sub_callback, + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + callback_group=self.sub_callback_group, + ) + + def __init_publishers(self): + """Initializes the publishers of this node. Publishers update ROS topics so that other ROS + nodes in the system can utilize the data produced by this node. + """ + self.get_logger().debug("Initializing publishers...") + self.__gps_pub = self.create_publisher( + msg_type=GPS, + topic=Constants.PHYSICS_ENGINE_PUBLISHERS.GPS, + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + ) + self.__wind_sensors_pub = self.create_publisher( + msg_type=WindSensors, + topic=Constants.PHYSICS_ENGINE_PUBLISHERS.WIND_SENSORS, + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + ) + self.__kinematics_pub = self.create_publisher( + msg_type=SimWorldState, + topic=Constants.PHYSICS_ENGINE_PUBLISHERS.KINEMATICS, + qos_profile=self.get_parameter("qos_depth").get_parameter_value().integer_value, + ) + + def __init_action_clients(self): + """Initializes the action clients of this node. Action clients initiate requests to the + action server to perform longer running tasks like rudder actuation, sail actuation, etc. + while also periodically receiving feedback from the action server as it completes its task. + """ + self.get_logger().debug("Initializing action clients...") + self.__rudder_actuation_action_client = ActionClient( + node=self, + action_type=SimRudderActuation, + action_name=Constants.ACTION_NAMES.RUDDER_ACTUATION, + callback_group=self.rudder_action_callback_group, + ) + self.__sail_actuation_action_client = ActionClient( + node=self, + action_type=SimSailTrimTabActuation, + action_name=Constants.ACTION_NAMES.SAIL_ACTUATION, + callback_group=self.sail_action_callback_group, + ) + + def __init_timer_callbacks(self): + """Initializes timer callbacks of this node. Timer callbacks are executed periodically with + a specified execution frequency. + """ + self.get_logger().debug("Initializing timer callbacks...") + + # Publishing data to ROS topics + self.create_timer( + timer_period_sec=self.pub_period, + callback=self.__publish, + callback_group=self.pub_callback_group, + ) + + # Requesting a rudder actuation + self.create_timer( + timer_period_sec=self.get_parameter("rudder.actuation_request_period_sec") + .get_parameter_value() + .double_value, + callback=self.__rudder_action_send_goal, + callback_group=self.rudder_action_callback_group, + ) + + # Requesting a sail actuation + self.create_timer( + timer_period_sec=self.get_parameter("wingsail.actuation_request_period_sec") + .get_parameter_value() + .double_value, + callback=self.__sail_action_send_goal, + callback_group=self.sail_action_callback_group, + ) + + # PUBLISHER CALLBACKS + def __publish(self): + """Synchronously publishes data to all publishers at once.""" + # TODO Get updated boat state and publish (should this be separate from publishing?) + # TODO Get wind sensor data and publish (should this be separate from publishing?) + self.__publish_gps() + self.__publish_wind_sensors() + self.__publish_kinematics() + self.__publish_counter += 1 + + def __publish_gps(self): + """Publishes mock GPS data.""" + # TODO Update to publish real data + msg = GPS() + msg.lat_lon.latitude = 0.0 + msg.lat_lon.longitude = 0.0 + msg.speed.speed = 0.0 + msg.heading.heading = 0.0 + + self.gps_pub.publish(msg) + self.get_logger().info( + f"Publishing to {self.gps_pub.topic}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + + def __publish_wind_sensors(self): + """Publishes mock wind sensor data.""" + # TODO Update to publish real data + windSensor1 = WindSensor() + windSensor1.speed.speed = 0.0 + windSensor1.direction = 0 + + windSensor2 = WindSensor() + windSensor2.speed.speed = 0.0 + windSensor2.direction = 0 + + msg = WindSensors() + msg.wind_sensors = [windSensor1, windSensor2] + + self.wind_sensors_pub.publish(msg) + self.get_logger().info( + f"Publishing to {self.wind_sensors_pub.topic}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + + def __publish_kinematics(self): + """Publishes the kinematics data of the simulated boat.""" + # TODO Update to publish real data + msg = SimWorldState() + + msg.global_gps.lat_lon.latitude = 0.0 + msg.global_gps.lat_lon.longitude = 0.0 + msg.global_gps.speed.speed = 0.0 + msg.global_gps.heading.heading = 0.0 + + msg.global_pose.position.x = 0.0 + msg.global_pose.position.y = 0.0 + msg.global_pose.position.z = 0.0 + msg.global_pose.orientation.x = 0.0 + msg.global_pose.orientation.y = 0.0 + msg.global_pose.orientation.z = 0.0 + msg.global_pose.orientation.w = 1.0 + + msg.wind_velocity.x = 0.0 + msg.wind_velocity.y = 0.0 + msg.wind_velocity.z = 0.0 + + msg.current_velocity.x = 0.0 + msg.current_velocity.y = 0.0 + msg.current_velocity.z = 0.0 + + sec, nanosec = divmod(self.pub_period * self.publish_counter, 1) + msg.header.stamp.sec = int(sec) + msg.header.stamp.nanosec = int(nanosec * 1e9) + msg.header.frame_id = str(self.publish_counter) + + self.kinematics_pub.publish(msg) + + self.get_logger().info( + f"Publishing to {self.kinematics_pub.topic}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + + # SUBSCRIPTION CALLBACKS + def __desired_heading_sub_callback(self, msg: DesiredHeading): + """Stores the latest desired heading data. + + Args: + msg (DesiredHeading): The desired heading data from local pathfinding. + """ + self.get_logger().info( + f"Received data from {self.desired_heading_sub.topic}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + self.__desired_heading = msg + + def __sail_trim_tab_angle_sub_callback(self, msg: SailCmd): + """Stores the latest trim tab angle from the controller. + + Args: + msg (SailCmd): The desired trim tab angle. + """ + self.get_logger().info( + f"Received data from {self.sail_trim_tab_angle_sub.topic}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + self.__sail_trim_tab_angle = msg.trim_tab_angle_degrees + + # RUDDER ACTUATION ACTION CLIENT CALLBACKS + @require_all_subs_active + def __rudder_action_send_goal(self): + """Asynchronously sends a goal request to the rudder actuation action server + and registers a callback to execute when the server routine is complete. + + All subscriptions of this node must be active for a successful action execution. + """ + self.get_logger().debug("Initiating goal request for rudder actuation action") + + # Create the goal message + goal_msg = SimRudderActuation.Goal() + goal_msg.desired_heading = self.desired_heading + + action_send_goal_timeout_sec = ( + self.get_parameter("action_send_goal_timeout_sec").get_parameter_value().double_value + ) + + # Wait for the action server to be ready (the low-level control node) + is_request_timed_out = not self.rudder_actuation_action_client.wait_for_server( + timeout_sec=action_send_goal_timeout_sec + ) + + if is_request_timed_out: + self.get_logger().warn( + "Rudder actuation action goal request timed out after " + + f"{action_send_goal_timeout_sec} seconds. Aborting..." + ) + else: + send_goal_future = self.rudder_actuation_action_client.send_goal_async( + goal=goal_msg, feedback_callback=self.__rudder_action_feedback_callback + ) + send_goal_future.add_done_callback(self.__rudder_action_goal_response_callback) + self.get_logger().debug("Completed goal request for rudder actuation action") + + def __rudder_action_goal_response_callback(self, future: Future): + """Prepares the execution process after the rudder action routine is complete. This + function executes when the rudder actuation goal request has been acknowledged. + + Args: + future (Future): The outcome of the goal request in the future. + """ + goal_handle: Optional[ClientGoalHandle] = future.result() + if (not goal_handle) or (not goal_handle.accepted): + self.get_logger().warn("Attempted to send rudder actuation goal, but it was rejected") + return + self.get_logger().debug("Rudder actuation goal was accepted. Beginning rudder actuation") + rudder_get_result_future = goal_handle.get_result_async() + rudder_get_result_future.add_done_callback(self.__rudder_action_get_result_callback) + + def __rudder_action_get_result_callback(self, future: Future): + """The execution process after the rudder action routine is complete. + + Args: + future (Future): The outcome of the rudder action routine in the future. + """ + result = future.result().result + self.get_logger().debug( + "Rudder actuation action finished with a heading residual of " + + f"{result.remaining_angular_distance:.2f} rad and final " + + f"rudder angle of {self.rudder_angle:.2f} rad" + ) + + def __rudder_action_feedback_callback(self, feedback_msg: SimRudderActuation_FeedbackMessage): + """Updates the rudder angle as the rudder action routine executes. As the action routine + publishes feedback, this function is executed. + + Args: + feedback_msg (SimRudderActuation_FeedbackMessage): The feedback message. + """ + self.__rudder_angle = feedback_msg.feedback.rudder_angle + self.get_logger().info( + f"Received rudder angle of {self.rudder_angle:.2f} rad from action " + + f"{self.rudder_actuation_action_client._action_name}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + + # SAIL ACTUATION ACTION CLIENT CALLBACKS + @require_all_subs_active + def __sail_action_send_goal(self): + """Asynchronously sends a goal request to the sail actuation action server + and registers a callback to execute when the server routine is complete. + + All subscriptions of this node must be active for a successful action execution. + """ + self.get_logger().debug("Initiating goal request for sail actuation action") + + # Create the goal message + goal_msg = SimSailTrimTabActuation.Goal() + goal_msg.desired_angular_position = self.sail_trim_tab_angle + + action_send_goal_timeout_sec = ( + self.get_parameter("action_send_goal_timeout_sec").get_parameter_value().double_value + ) + + # Wait for the action server to be ready (the low-level control node) + is_request_timed_out = not self.sail_actuation_action_client.wait_for_server( + timeout_sec=action_send_goal_timeout_sec + ) + + if is_request_timed_out: + self.get_logger().warn( + "Sail actuation action goal request timed out after " + + f"{action_send_goal_timeout_sec} seconds. Aborting..." + ) + else: + send_goal_future = self.sail_actuation_action_client.send_goal_async( + goal=goal_msg, feedback_callback=self.__sail_action_feedback_callback + ) + send_goal_future.add_done_callback(self.__sail_action_goal_response_callback) + self.get_logger().debug("Completed goal request for sail actuation action") + + def __sail_action_goal_response_callback(self, future: Future): + """Prepares the execution process after the sail action routine is complete. This + function executes when the sail actuation goal request has been acknowledged. + + Args: + future (Future): The outcome of the goal request in the future. + """ + goal_handle: Optional[ClientGoalHandle] = future.result() + if (not goal_handle) or (not goal_handle.accepted): + self.get_logger().warn("Attempted to send sail actuation goal, but it was rejected") + return + self.get_logger().debug("Sail actuation goal was accepted. Beginning sail actuation") + sail_get_result_future = goal_handle.get_result_async() + sail_get_result_future.add_done_callback(self.__sail_action_get_result_callback) + + def __sail_action_get_result_callback(self, future: Future): + """The execution process after the sail action routine is complete. + + Args: + future (Future): The outcome of the sail action routine in the future. + """ + result = future.result().result + self.get_logger().debug( + "Sail actuation action finished with an angular residual of " + + f"{result.remaining_angular_distance:.2f} rad and final " + + f"trim tab angle of {self.sail_trim_tab_angle:.2f} rad" + ) + + def __sail_action_feedback_callback( + self, feedback_msg: SimSailTrimTabActuation_FeedbackMessage + ): + """Updates the sail trim tab angle as the sail action routine executes. As the action + routine publishes feedback, this function is executed. + + Args: + feedback_msg (SimSailTrimTabActuation_FeedbackMessage): The feedback message. + """ + self.__sail_trim_tab_angle = feedback_msg.feedback.current_angular_position + self.get_logger().info( + f"Received sail trim tab angle of {self.sail_trim_tab_angle:.2f} rad from action " + + f"{self.sail_actuation_action_client._action_name}", + throttle_duration_sec=self.get_parameter("info_log_throttle_period_sec") + .get_parameter_value() + .double_value, + ) + + # CLASS PROPERTY PUBLIC GETTERS + @property + def is_multithreading_enabled(self) -> bool: + return self.__is_multithreading_enabled + + @property + def pub_callback_group(self) -> CallbackGroup: + return self.__pub_callback_group + + @property + def sub_callback_group(self) -> CallbackGroup: + return self.__sub_callback_group + + @property + def rudder_action_callback_group(self) -> CallbackGroup: + return self.__rudder_action_callback_group + + @property + def sail_action_callback_group(self) -> CallbackGroup: + return self.__sail_action_callback_group + + @property + def pub_period(self) -> float: + return self.get_parameter("pub_period_sec").get_parameter_value().double_value + + @property + def publish_counter(self) -> int: + return self.__publish_counter + + @property + def gps_pub(self) -> Publisher: + return self.__gps_pub + + @property + def wind_sensors_pub(self) -> Publisher: + return self.__wind_sensors_pub + + @property + def kinematics_pub(self) -> Publisher: + return self.__kinematics_pub + + @property + def desired_heading(self) -> Optional[DesiredHeading]: + return self.__desired_heading + + @property + def desired_heading_sub(self) -> Subscription: + return self.__desired_heading_sub + + @property + def rudder_angle(self) -> Scalar: + return self.__rudder_angle + + @property + def sail_trim_tab_angle(self) -> Scalar: + return self.__sail_trim_tab_angle + + @property + def sail_trim_tab_angle_sub(self) -> Subscription: + return self.__sail_trim_tab_angle_sub + + @property + def rudder_actuation_action_client(self) -> ActionClient: + return self.__rudder_actuation_action_client + + @property + def sail_actuation_action_client(self) -> ActionClient: + return self.__sail_actuation_action_client + + +if __name__ == "__main__": + main() diff --git a/src/boat_simulator/launch/main_launch.py b/src/boat_simulator/launch/main_launch.py new file mode 100644 index 000000000..928d5042f --- /dev/null +++ b/src/boat_simulator/launch/main_launch.py @@ -0,0 +1,182 @@ +"""Launch file that runs all nodes for the boat simulator ROS package.""" + +import importlib +import os +from typing import List, Tuple + +from launch_ros.actions import Node + +import boat_simulator.common.constants as Constants +from launch.actions import DeclareLaunchArgument, OpaqueFunction +from launch.launch_context import LaunchContext +from launch.launch_description import LaunchDescription +from launch.some_substitutions_type import SomeSubstitutionsType +from launch.substitutions import LaunchConfiguration + +# Local launch arguments and constants +PACKAGE_NAME = "boat_simulator" + +# Add args with DeclareLaunchArguments object(s) and utilize in setup_launch() +LOCAL_LAUNCH_ARGUMENTS: List[DeclareLaunchArgument] = [ + DeclareLaunchArgument( + name="enable_sim_multithreading", + default_value="false", + choices=["true", "false"], + description="Enable multithreaded execution of callbacks in the boat simulator", + ), + DeclareLaunchArgument( + name="enable-data-collection", + default_value="false", + choices=["true", "false"], + description="Enable data collection in the boat simulator", + ), +] + + +def generate_launch_description() -> LaunchDescription: + """The launch file entry point. Generates the launch description for the `boat_simulator` + package. + + Returns: + LaunchDescription: The launch description. + """ + global_launch_arguments, global_environment_vars = get_global_launch_arguments() + return LaunchDescription( + [ + *global_launch_arguments, + *global_environment_vars, + *LOCAL_LAUNCH_ARGUMENTS, + OpaqueFunction(function=setup_launch), + ] + ) + + +def get_global_launch_arguments() -> Tuple: + """Gets the global launch arguments and environment variables from the global launch file. + + Returns: + Tuple: The global launch arguments and environment variables. + """ + ros_workspace = os.getenv("ROS_WORKSPACE", default="/workspaces/sailbot_workspace") + global_main_launch = os.path.join(ros_workspace, "src", "global_launch", "main_launch.py") + spec = importlib.util.spec_from_file_location("global_launch", global_main_launch) + if spec is None: + raise ImportError(f"Couldn't import global_launch module from {global_main_launch}") + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] # spec is not None + spec.loader.exec_module(module) # type: ignore[union-attr] # spec is not None + global_launch_arguments = module.GLOBAL_LAUNCH_ARGUMENTS + global_environment_vars = module.ENVIRONMENT_VARIABLES + return global_launch_arguments, global_environment_vars + + +def setup_launch(context: LaunchContext) -> List[Node]: + """Collects launch descriptions that describe the system behavior in the `boat_simulator` + package. + + Args: + context (LaunchContext): The current launch context. + + Returns: + List[Node]: Nodes to launch. + """ + launch_description_entities = list() + launch_description_entities.append(get_physics_engine_description(context)) + launch_description_entities.append(get_low_level_control_description(context)) + launch_description_entities.append(get_data_collection_description(context)) + return launch_description_entities + + +def get_physics_engine_description(context: LaunchContext) -> Node: + """Gets the launch description for the physics engine node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the physics engine node. + """ + node_name = "physics_engine_node" + ros_parameters = [LaunchConfiguration("config").perform(context)] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + local_arguments: List[SomeSubstitutionsType] = [ + Constants.MULTITHREADING_CLI_ARG_NAME, + [LaunchConfiguration("enable_sim_multithreading")], + ] + + node = Node( + package=PACKAGE_NAME, + executable=node_name, + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + arguments=local_arguments, + ) + + return node + + +def get_low_level_control_description(context: LaunchContext) -> Node: + """Gets the launch description for the low level control node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the low level control node. + """ + node_name = "low_level_control_node" + ros_parameters = [LaunchConfiguration("config").perform(context)] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + local_arguments: List[SomeSubstitutionsType] = [ + Constants.MULTITHREADING_CLI_ARG_NAME, + [LaunchConfiguration("enable_sim_multithreading")], + ] + + node = Node( + package=PACKAGE_NAME, + executable=node_name, + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + arguments=local_arguments, + ) + + return node + + +def get_data_collection_description(context: LaunchContext) -> Node: + """Gets the launch description for the data collection node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the data collection node. + """ + node_name = "data_collection_node" + ros_parameters = [LaunchConfiguration("config").perform(context)] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + local_arguments: List[SomeSubstitutionsType] = [ + Constants.DATA_COLLECTION_CLI_ARG_NAME, + [LaunchConfiguration("enable-data-collection")], + ] + + node = Node( + package=PACKAGE_NAME, + executable=node_name, + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + arguments=local_arguments, + ) + + return node diff --git a/src/boat_simulator/package.xml b/src/boat_simulator/package.xml new file mode 100644 index 000000000..55401ed73 --- /dev/null +++ b/src/boat_simulator/package.xml @@ -0,0 +1,27 @@ + + + + boat_simulator + 0.0.0 + UBC Sailbot's Boat Simulator + Devon Friend + MIT + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + custom_interfaces + ros2launch + rosidl_parser + + + python3-numpy + python3-scipy + + + ament_python + + diff --git a/src/boat_simulator/resource/boat_simulator b/src/boat_simulator/resource/boat_simulator new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/setup.cfg b/src/boat_simulator/setup.cfg new file mode 100644 index 000000000..bb8db29de --- /dev/null +++ b/src/boat_simulator/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/boat_simulator +[install] +install_scripts=$base/lib/boat_simulator diff --git a/src/boat_simulator/setup.py b/src/boat_simulator/setup.py new file mode 100644 index 000000000..b0c4585fb --- /dev/null +++ b/src/boat_simulator/setup.py @@ -0,0 +1,35 @@ +from glob import glob +from os.path import join + +from setuptools import find_packages, setup + +PACKAGE_NAME = "boat_simulator" +REQUIRED_MODULES = ["setuptools", "numpy", "scipy"] + +setup( + name=PACKAGE_NAME, + version="0.0.0", + packages=find_packages(exclude=["tests"]), + install_requires=REQUIRED_MODULES, + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + PACKAGE_NAME]), + ("share/" + PACKAGE_NAME, ["package.xml"]), + (join("share", PACKAGE_NAME), glob("launch/*_launch.py")), + ], + zip_safe=True, + maintainer="Devon Friend", + maintainer_email="software@ubcsailbot.org", + description="UBC Sailbot's Boat Simulator", + license="MIT", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "physics_engine_node = " + + "boat_simulator.nodes.physics_engine.physics_engine_node:main", + "low_level_control_node = " + + "boat_simulator.nodes.low_level_control.low_level_control_node:main", + "data_collection_node = " + + "boat_simulator.nodes.data_collection.data_collection_node:main", + ], + }, +) diff --git a/src/boat_simulator/tests/__init__.py b/src/boat_simulator/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/integration/__init__.py b/src/boat_simulator/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/unit/__init__.py b/src/boat_simulator/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/unit/common/__init__.py b/src/boat_simulator/tests/unit/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/unit/common/test_generators.py b/src/boat_simulator/tests/unit/common/test_generators.py new file mode 100644 index 000000000..86a7194b1 --- /dev/null +++ b/src/boat_simulator/tests/unit/common/test_generators.py @@ -0,0 +1,106 @@ +"""Tests classes and functions in boat_simulator/common/generators.py""" + +import numpy as np +import pytest + + +from boat_simulator.common.generators import ( + ConstantGenerator, + GaussianGenerator, + MVGaussianGenerator, +) + + +class TestGaussianGenerator: + @pytest.mark.parametrize( + "mean, stdev, threshold", + [(1.0, 1.0, 0.2), (10, 0.0, 0.0), (-1.0, 0.0, 0.2), (4.2, 5.1, 0.2), (120.0, 120.0, 10.0)], + ) + def test_gaussian_generator(self, mean: float, stdev: float, threshold): + """ + This test compares the mean and standard deviation computed from an array + of generated sequence of scalars originating from the GaussianGenerator to the expected + + Args: + mean (float): Describes the mean of the Gaussian distribution used + by the GaussianGenerator. + stdev (float): Describes the standard deviation of the Gaussian distribution + used by the GaussianGenerator. + threshold (float): Threshold allowed between expected mean and standard deviation + to computed mean and standard deviation. + """ + NUM_SAMPLES = 50000 + + samples = np.zeros(NUM_SAMPLES) + + generator = GaussianGenerator(mean=mean, stdev=stdev) + + for i in range(NUM_SAMPLES): + samples[i] = generator.next() + + sample_mean = np.mean(samples) + sample_std = np.std(samples) + + assert np.allclose(sample_std, stdev, atol=threshold) + assert np.allclose(sample_mean, mean, atol=threshold) + + +class TestMVGaussianGenerator: + @pytest.mark.parametrize( + "mean, cov", + [ + (np.array([1, 1]), np.eye(2)), + (np.array([1, 2]), np.array([[2, 1], [1, 2]])), + (np.array([4, 5]), np.array([[3, 1], [1, 3]])), + (np.array([100, 50]), np.array([[10, 5], [5, 10]])), + (np.array([120, 130]), np.array([[10, 5], [5, 10]])), + (np.array([1, 1, 1]), np.eye(3)), + (np.array([1, 2, 3]), np.array([[2, 1, 1], [1, 2, 1], [1, 1, 2]])), + (np.array([4, 5, 6]), np.array([[3, 2, 2], [2, 3, 2], [2, 2, 3]])), + ], + ) + def test_multivariate_vector_generation_2d(self, mean, cov): + """This test compares the calculated cov/mean of generated vectors against + expected mean/cov arrays + + Args: + mean (array): Input array of size n with average 1 + cov (array): Identity matrix based on mean array size (I_n) + """ + NUM_SAMPLES = 10000 + samples = np.zeros(shape=(NUM_SAMPLES, mean.size)) + generator = MVGaussianGenerator(mean=mean, cov=cov) + for i in range(NUM_SAMPLES): + samples[i, :] = generator.next() + sample_mean = np.mean(samples, axis=0) + sample_cov = np.cov(samples, rowvar=False) + + assert np.allclose(sample_cov, cov, atol=0.2) + assert np.isclose(sample_mean, mean, 0.1).all() + + +class TestConstantGenerator: + @pytest.mark.parametrize( + "constant", + [ + 50.22, + 10111, + 12.3456789, + (np.array([1]),), + (np.array([1, 2.2568]),), + (np.array([1 * 7 / 6, 2.75, 3]),), + (np.array([[1.0001, 1], [1, 1.5674]]),), + (np.array([[1091237, 12319.6], [784520, 1]]),), + (np.array([[1.3333, 2.6666, 3.9999], [3.0001, 2, 1]]),), + ], + ) + def test_constant_vector_generator(self, constant): + """This test compares if the generated vector is exactly + the same as the initial vector + + Args: + constant (array): The constant array to return upon array generation. + """ + generator = ConstantGenerator(constant=constant) + samples = generator.next() + assert np.isclose(constant, samples, 0.1).all() diff --git a/src/boat_simulator/tests/unit/common/test_gps_sensor.py b/src/boat_simulator/tests/unit/common/test_gps_sensor.py new file mode 100644 index 000000000..60f2568d8 --- /dev/null +++ b/src/boat_simulator/tests/unit/common/test_gps_sensor.py @@ -0,0 +1,145 @@ +from boat_simulator.common.sensors import GPS +import numpy as np +from boat_simulator.common.generators import ( + ConstantGenerator, + GaussianGenerator, +) + + +class TestGPS: + def test_gps_init(self): + lat_lon = np.array([1, 0]) + speed = 100 + heading = 1.09 + error_fn = None + + gps = GPS( + lat_lon=lat_lon, + speed=speed, + heading=heading, + lat_lon_noisemaker=error_fn, + speed_noisemaker=error_fn, + heading_noisemaker=error_fn, + ) + + assert (gps.lat_lon == lat_lon).all() + assert gps.speed == speed + assert gps.heading == heading + assert gps.lat_lon_noisemaker is error_fn + assert gps.speed_noisemaker is error_fn + assert gps.heading_noisemaker is error_fn + + def test_gps_init_implicit_error_fn(self): + lat_lon = np.array([1, 0]) + speed = 100 + heading = 1.09 + + gps = GPS( + lat_lon=lat_lon, + speed=speed, + heading=heading, + ) + + assert (gps.lat_lon == lat_lon).all() + assert gps.speed == speed + assert gps.heading == heading + for noisemaker in [ + gps.lat_lon_noisemaker, + gps.speed_noisemaker, + gps.heading_noisemaker, + ]: + assert noisemaker is None + + def test_gps_read_no_error(self): + lat_lon = np.array([1, 0]) + speed = np.random.randint(0, 100) + heading = np.random.rand() + + gps = GPS( + lat_lon=lat_lon, + speed=speed, + heading=heading, + ) + + assert (gps.read("lat_lon") == lat_lon).all() + assert gps.read("speed") == speed + assert gps.read("heading") == heading + + def test_gps_read_constant_error(self): + lat_lon = np.array([1, 0]) + speed = np.random.randint(0, 100) + heading = np.random.rand() + constant = 3.01 + error_fn = ConstantGenerator(constant=constant) + + gps = GPS( + lat_lon=lat_lon, + speed=speed, + heading=heading, + lat_lon_noisemaker=error_fn, + speed_noisemaker=error_fn, + heading_noisemaker=error_fn, + ) + + assert (gps.read("lat_lon") == lat_lon + constant).all() + assert gps.read("speed") == speed + constant + assert gps.read("heading") == heading + constant + + def test_gps_gaussian_error(self): + lat_lon = np.array([1, 0]) + speed = np.random.randint(0, 100) + heading = np.random.rand() + mean = 0 + stdev = 1 + + error_fn = GaussianGenerator(mean=mean, stdev=stdev) + + gps = GPS( + lat_lon=lat_lon, + speed=speed, + heading=heading, + lat_lon_noisemaker=error_fn, + speed_noisemaker=error_fn, + heading_noisemaker=error_fn, + ) + + NUM_READINGS = 10000 + speed_readings = np.zeros(NUM_READINGS) + heading_readings = np.zeros(NUM_READINGS) + lat_lon_readings = np.zeros(shape=(NUM_READINGS, 2)) + for i in range(NUM_READINGS): + speed_readings[i] = gps.read("speed") + heading_readings[i] = gps.read("heading") + lat_lon_readings[i, :] = gps.read("lat_lon") + + for reading, init_data in zip( + [speed_readings, heading_readings, lat_lon_readings], + [speed, heading, lat_lon], + ): + sample_mean = np.mean(reading, axis=0) + assert np.isclose(sample_mean, mean + init_data, atol=0.1).all() + + def test_wind_sensor_update(self): + lat_lon = np.array([0, 0]) + speed = 0 + heading = 0 + + gps = GPS( + lat_lon=lat_lon, + speed=speed, + heading=heading, + ) + + NUM_READINGS = 100 + for i in range(NUM_READINGS): + speed_reading = gps.read("speed") + assert speed_reading == i + gps.update(speed=i + 1) + + heading_reading = gps.read("heading") + assert heading_reading == i + gps.update(heading=i + 1) + + lat_lon_reading = gps.read("lat_lon") + assert (lat_lon_reading == np.array([i, i])).all() + gps.update(lat_lon=(lat_lon_reading + 1)) diff --git a/src/boat_simulator/tests/unit/common/test_unit_conversions.py b/src/boat_simulator/tests/unit/common/test_unit_conversions.py new file mode 100644 index 000000000..9d9d94429 --- /dev/null +++ b/src/boat_simulator/tests/unit/common/test_unit_conversions.py @@ -0,0 +1,362 @@ +"""Tests classes and functions in boat_simulator/common/unit_conversions.py""" + +import math +import numpy as np + +import pytest + +from boat_simulator.common.unit_conversions import ( + ConversionFactor, + ConversionFactors, + UnitConverter, +) + + +class TestConversionFactor: + @pytest.mark.parametrize( + "factor, initial_value, expected_converted_value", + [(60, 1, 60), (0.5, 2, 0.5 * 2), (-10.2, 14.22, -10.2 * 14.22)], + ) + def test_forward_convert(self, factor, initial_value, expected_converted_value): + conversion_factor = ConversionFactor(factor=factor) + actual_converted_value = conversion_factor.forward_convert(initial_value) + assert math.isclose(actual_converted_value, expected_converted_value) + + @pytest.mark.parametrize( + "factor, initial_value, expected_converted_value", + [(60, 60, 1), (0.5, 1, 1 / 0.5), (-10.2, -10.2 * 14.22, 14.22)], + ) + def test_backward_convert(self, factor, initial_value, expected_converted_value): + conversion_factor = ConversionFactor(factor=factor) + actual_converted_value = conversion_factor.backward_convert(initial_value) + assert math.isclose(actual_converted_value, expected_converted_value) + + @pytest.mark.parametrize("factor", [1, 2, 5, 10, 0.5, -18.9, -17, -13.33]) + def test_inverse(self, factor): + conversion_factor = ConversionFactor(factor=factor) + inverse_conversion_factor = conversion_factor.inverse() + assert math.isclose(inverse_conversion_factor.factor, 1 / factor) + assert math.isclose(inverse_conversion_factor.factor, conversion_factor.inverse_factor) + + @pytest.mark.parametrize( + "factor1, factor2, expected_product_factor", + [(1, 1, 1), (2, 5, 10), (1 / 2, 2, 1), (0, 1, 0)], + ) + def test_multiplication(self, factor1, factor2, expected_product_factor): + conversion_factor1 = ConversionFactor(factor=factor1) + conversion_factor2 = ConversionFactor(factor=factor2) + product_conversion_factor = conversion_factor1 * conversion_factor2 + reverse_product_conversion_factor = conversion_factor2 * conversion_factor1 + assert math.isclose(product_conversion_factor.factor, expected_product_factor) + assert math.isclose(reverse_product_conversion_factor.factor, expected_product_factor) + assert math.isclose( + product_conversion_factor.factor, reverse_product_conversion_factor.factor + ) + + +class TestUnitConverter: + def test_init(self): + """ + Test instance attribute assignment using attributes or using kwargs into constructor + """ + unit_converter1 = UnitConverter( + prop1=ConversionFactors.sec_to_min, prop2=ConversionFactors.sec_to_h + ) + assert unit_converter1.prop1 == ConversionFactors.sec_to_min + assert unit_converter1.prop2 == ConversionFactors.sec_to_h + + converted_values1 = unit_converter1.convert(prop1=120, prop2=3600) + + assert converted_values1["prop1"] == 2.0 + assert converted_values1["prop2"] == 1.0 + + conversion_factors = { + "prop1": ConversionFactors.sec_to_min, + "prop2": ConversionFactors.sec_to_h, + } + values = {"prop1": 120, "prop2": 3600} + unit_converter2 = UnitConverter(**conversion_factors) + + assert unit_converter2.prop1 == ConversionFactors.sec_to_min + assert unit_converter2.prop2 == ConversionFactors.sec_to_h + + converted_values2 = unit_converter2.convert(**values) + + assert converted_values2["prop1"] == 2.0 + assert converted_values2["prop2"] == 1.0 + + def test_convert_m_km(self): + unit_converter = UnitConverter( + m_to_km=ConversionFactors.m_to_km, km_to_m=ConversionFactors.km_to_m + ) + + converted_values = unit_converter.convert(m_to_km=1000, km_to_m=1) + + assert converted_values["m_to_km"] == 1 + assert converted_values["km_to_m"] == 1000 + + def test_convert_cm_m(self): + unit_converter = UnitConverter( + cm_to_m=ConversionFactors.cm_to_m, m_to_cm=ConversionFactors.m_to_cm + ) + + converted_values = unit_converter.convert(cm_to_m=100, m_to_cm=1) + + assert converted_values["cm_to_m"] == 1 + assert converted_values["m_to_cm"] == 100 + + def test_convert_cm_km(self): + unit_converter = UnitConverter( + km_to_cm=ConversionFactors.km_to_cm, cm_to_km=ConversionFactors.cm_to_km + ) + + converted_values = unit_converter.convert(km_to_cm=1, cm_to_km=1e5) + + assert converted_values["km_to_cm"] == 1e5 + assert converted_values["cm_to_km"] == 1 + + def test_convert_ft_m(self): + unit_converter = UnitConverter( + m_to_ft=ConversionFactors.m_to_ft, ft_to_m=ConversionFactors.ft_to_m + ) + + converted_values = unit_converter.convert(m_to_ft=100.0, ft_to_m=10) + + assert math.isclose(converted_values["m_to_ft"], 328.084, abs_tol=1e-6) + assert math.isclose(converted_values["ft_to_m"], 3.048, abs_tol=1e-6) + + def test_convert_ft_mi(self): + unit_converter = UnitConverter( + mi_to_ft=ConversionFactors.mi_to_ft, ft_to_mi=ConversionFactors.ft_to_mi + ) + + converted_values = unit_converter.convert(mi_to_ft=1, ft_to_mi=1000) + + assert converted_values["mi_to_ft"] == 5280 + assert math.isclose(converted_values["ft_to_mi"], 0.189394, abs_tol=1e-6) + + def test_convert_m_mi(self): + unit_converter = UnitConverter( + mi_to_m=ConversionFactors.mi_to_m, m_to_mi=ConversionFactors.m_to_mi + ) + + converted_values = unit_converter.convert(mi_to_m=10, m_to_mi=1e4) + + assert math.isclose(converted_values["mi_to_m"], 16093.44, abs_tol=1e-6) + assert math.isclose(converted_values["m_to_mi"], 6.2137119, abs_tol=1e-6) + + def test_convert_km_mi(self): + unit_converter = UnitConverter( + km_to_mi=ConversionFactors.km_to_mi, mi_to_km=ConversionFactors.mi_to_km + ) + + converted_values = unit_converter.convert(km_to_mi=1.0, mi_to_km=10.0) + + assert math.isclose(converted_values["km_to_mi"], 0.62137119, abs_tol=1e-6) + assert converted_values["mi_to_km"] == 16.09344 + + def test_convert_mi_nautical_mi(self): + unit_converter = UnitConverter( + nat_mi_to_mi=ConversionFactors.nautical_mi_to_mi, + mi_to_nat_mi=ConversionFactors.mi_to_nautical_mi, + ) + + converted_values = unit_converter.convert(nat_mi_to_mi=1.0, mi_to_nat_mi=1.0) + + assert converted_values["nat_mi_to_mi"] == 1.15078 + assert math.isclose(converted_values["mi_to_nat_mi"], 0.868976, abs_tol=1e-6) + + def test_convert_km_nautical_mi(self): + unit_converter = UnitConverter( + nat_mi_to_km=ConversionFactors.nautical_mi_to_km, + km_to_nat_mi=ConversionFactors.km_to_nautical_mi, + ) + + converted_values = unit_converter.convert(nat_mi_to_km=1.0, km_to_nat_mi=1.0) + + assert converted_values["nat_mi_to_km"] == 1.852 + assert math.isclose(converted_values["km_to_nat_mi"], 0.539957, abs_tol=1e-6) + + def test_convert_sec_min(self): + unit_converter = UnitConverter( + sec_to_min=ConversionFactors.sec_to_min, + min_to_sec=ConversionFactors.min_to_sec, + ) + + converted_values = unit_converter.convert(sec_to_min=120.0, min_to_sec=10.0) + + assert converted_values["sec_to_min"] == 2.0 + assert converted_values["min_to_sec"] == 600.0 + + def test_convert_sec_h(self): + unit_converter = UnitConverter( + sec_to_h=ConversionFactors.sec_to_h, + h_to_sec=ConversionFactors.h_to_sec, + ) + + converted_values = unit_converter.convert(sec_to_h=3600, h_to_sec=1) + + assert converted_values["sec_to_h"] == 1 + assert converted_values["h_to_sec"] == 3600 + + def test_convert_min_h(self): + unit_converter = UnitConverter( + min_to_h=ConversionFactors.min_to_h, + h_to_min=ConversionFactors.h_to_min, + ) + + converted_values = unit_converter.convert(min_to_h=90, h_to_min=2) + + assert converted_values["min_to_h"] == 1.5 + assert converted_values["h_to_min"] == 120 + + def test_convert_miPh_kmPh(self): + unit_converter = UnitConverter( + miPh_to_kmPh=ConversionFactors.miPh_to_kmPh, + kmPh_to_miPh=ConversionFactors.kmPh_to_miPh, + ) + + converted_values = unit_converter.convert(miPh_to_kmPh=10.0, kmPh_to_miPh=10.0) + + assert math.isclose(converted_values["miPh_to_kmPh"], 16.09344, abs_tol=1e-6) + assert math.isclose(converted_values["kmPh_to_miPh"], 6.2137119, abs_tol=1e-6) + + def test_convert_mPs_kmPh(self): + unit_converter = UnitConverter( + mPs_to_kmPh=ConversionFactors.mPs_to_kmPh, + kmPh_to_mPs=ConversionFactors.kmPh_to_mPs, + ) + + converted_values = unit_converter.convert(mPs_to_kmPh=1.0, kmPh_to_mPs=1.0) + + assert converted_values["mPs_to_kmPh"] == 3.6 + assert math.isclose(converted_values["kmPh_to_mPs"], 0.277778, abs_tol=1e-6) + + def test_convert_knots_kmPh(self): + unit_converter = UnitConverter( + knots_to_kmPh=ConversionFactors.knots_to_kmPh, + kmPh_to_knots=ConversionFactors.kmPh_to_knots, + ) + + converted_values = unit_converter.convert(knots_to_kmPh=1.0, kmPh_to_knots=1.0) + + assert math.isclose(converted_values["knots_to_kmPh"], 1.852, abs_tol=1e-6) + assert math.isclose(converted_values["kmPh_to_knots"], 0.539957, abs_tol=1e-6) + + def test_convert_knots_miPh(self): + unit_converter = UnitConverter( + knots_to_miPh=ConversionFactors.knots_to_miPh, + miPh_to_knots=ConversionFactors.miPh_to_knots, + ) + + converted_values = unit_converter.convert(knots_to_miPh=1.0, miPh_to_knots=1.0) + + assert math.isclose(converted_values["knots_to_miPh"], 1.15078, abs_tol=1e-6) + assert math.isclose(converted_values["miPh_to_knots"], 0.868976, abs_tol=1e-6) + + def test_convert_miPs2_mPs2(self): + unit_converter = UnitConverter( + miPs2_to_mPs2=ConversionFactors.miPs2_to_mPs2, + mPs2_to_miPs2=ConversionFactors.mPs2_to_miPs2, + ) + + converted_values = unit_converter.convert(miPs2_to_mPs2=1.0, mPs2_to_miPs2=1.0) + + assert math.isclose(converted_values["miPs2_to_mPs2"], 1609.344, abs_tol=1e-6) + assert math.isclose(converted_values["mPs2_to_miPs2"], 0.000621371192, abs_tol=1e-6) + + def test_convert_kmPs2_mPs2(self): + unit_converter = UnitConverter( + kmPs2_to_mPs2=ConversionFactors.kmPs2_to_mPs2, + mPs2_to_kmPs2=ConversionFactors.mPs2_to_kmPs2, + ) + + converted_values = unit_converter.convert(kmPs2_to_mPs2=1.0, mPs2_to_kmPs2=1.0) + + assert converted_values["kmPs2_to_mPs2"] == 1e3 + assert converted_values["mPs2_to_kmPs2"] == 1e-3 + + def test_convert_mPs2_knotsPs2(self): + unit_converter = UnitConverter( + mPs2_to_knotsPs2=ConversionFactors.mPs2_to_knotsPs2, + knotsPs2_to_mPs2=ConversionFactors.knotsPs2_to_mPs2, + ) + + converted_values = unit_converter.convert(mPs2_to_knotsPs2=1.0, knotsPs2_to_mPs2=1.0) + + assert math.isclose(converted_values["mPs2_to_knotsPs2"], 1.94384466, abs_tol=1e-6) + assert math.isclose(converted_values["knotsPs2_to_mPs2"], 0.514444, abs_tol=1e-6) + + def test_convert_kg_g(self): + unit_convertor = UnitConverter( + kg_to_g=ConversionFactors.kg_to_g, g_to_kg=ConversionFactors.g_to_kg + ) + + converted_values = unit_convertor.convert(kg_to_g=1.0, g_to_kg=1000.0) + + assert converted_values["kg_to_g"] == 1000.0 + assert converted_values["g_to_kg"] == 1.0 + + def test_convert_lb_g(self): + unit_convertor = UnitConverter( + lb_to_g=ConversionFactors.lb_to_g, g_to_lb=ConversionFactors.g_to_lb + ) + + converted_values = unit_convertor.convert(lb_to_g=2.5, g_to_lb=1.0) + + assert math.isclose(converted_values["lb_to_g"], 1133.980925, abs_tol=1e-6) + assert math.isclose(converted_values["g_to_lb"], 0.00220462, abs_tol=1e-6) + + def test_convert_kg_lb(self): + unit_convertor = UnitConverter( + kg_to_lb=ConversionFactors.kg_to_lb, lb_to_kg=ConversionFactors.lb_to_kg + ) + + converted_values = unit_convertor.convert(kg_to_lb=1.5, lb_to_kg=1.0) + + assert math.isclose(converted_values["kg_to_lb"], 3.3069339328, abs_tol=1e-6) + assert math.isclose(converted_values["lb_to_kg"], 0.45359237, abs_tol=1e-6) + + @pytest.mark.parametrize( + "degrees_to_rad, expected_result", + [(0, 0), (90, math.pi / 2), (180, math.pi), (360, 2 * math.pi), (-180, -math.pi)], + ) + def test_convert_degrees_to_rad(self, degrees_to_rad, expected_result): + unit_convertor = UnitConverter( + degrees_to_rad=ConversionFactors.degrees_to_rad, + ) + + converted_values = unit_convertor.convert(degrees_to_rad=degrees_to_rad) + + assert math.isclose(converted_values["degrees_to_rad"], expected_result, abs_tol=1e-6) + + @pytest.mark.parametrize( + "rad_to_degrees, expected_result", + [(0, 0), (math.pi / 2, 90), (math.pi, 180), (2 * math.pi, 360), (-math.pi, -180)], + ) + def test_convert_rad_to_degrees(self, rad_to_degrees, expected_result): + unit_convertor = UnitConverter( + rad_to_degrees=ConversionFactors.rad_to_degrees, + ) + + converted_values = unit_convertor.convert(rad_to_degrees=rad_to_degrees) + + assert math.isclose(converted_values["rad_to_degrees"], expected_result, abs_tol=1e-6) + + @pytest.mark.parametrize( + "km_to_m, expected_result", + [ + ([0, 0, 0], [0, 0, 0]), + ([0, 1, 2], [0, 1000, 2000]), + ([], []), + ([1], [1000]), + ], + ) + def test_convert_arraylike(self, km_to_m, expected_result): + unit_convertor = UnitConverter( + km_to_m=ConversionFactors.km_to_m, + ) + + converted_values = unit_convertor.convert(km_to_m=np.array(km_to_m)) + + assert np.array_equal(converted_values["km_to_m"], np.array(expected_result)) diff --git a/src/boat_simulator/tests/unit/common/test_utils.py b/src/boat_simulator/tests/unit/common/test_utils.py new file mode 100644 index 000000000..9bdaace10 --- /dev/null +++ b/src/boat_simulator/tests/unit/common/test_utils.py @@ -0,0 +1,112 @@ +"""Test the functions in boat_simulator/common/utils.py""" + +import math + +import numpy as np +import pytest + +from boat_simulator.common import utils + + +@pytest.mark.parametrize( + "test_input, expected_output", + [ + (math.pi, 180), + (math.pi / 2, 90), + (math.pi / 4, 45), + (0, 0), + (math.pi / 12, 180 / 12), + (-math.pi / 4.5, -180 / 4.5), + ], +) +def test_rad_to_degrees(test_input, expected_output): + actual_output = utils.rad_to_degrees(test_input) + assert math.isclose(actual_output, expected_output) + + +@pytest.mark.parametrize( + "test_input, expected_output", + [ + (180, math.pi), + (90, math.pi / 2), + (45, math.pi / 4), + (0, 0), + (180 / 12, math.pi / 12), + (-180 / 4.5, -math.pi / 4.5), + ], +) +def test_degrees_to_rad(test_input, expected_output): + actual_output = utils.degrees_to_rad(test_input) + assert math.isclose(actual_output, expected_output) + + +@pytest.mark.parametrize( + "angle, isDegrees, expected_output", + [ + # Degree tests + (0, True, 0), + (270, True, -90), + (-450, True, -90), + (360, True, 0), + ( + np.array([540, -540, 899, -899, 5, -30]), + True, + np.array([-180, -180, 179, -179, 5, -30]), + ), + # Radian tests + (0, False, 0), + (2 * math.pi, False, 0), + (3 / 2 * math.pi, False, -0.5 * math.pi), + (-2.5 * math.pi, False, -0.5 * math.pi), + (np.array([3 * math.pi, -3 * math.pi]), False, np.array([-math.pi, -math.pi])), + ( + np.array([4.44 * math.pi, -5.68 * math.pi]), + False, + np.array([0.44 * math.pi, 0.32 * math.pi]), + ), + ( + np.array([1 / 36 * math.pi, -0.334 * math.pi]), + False, + np.array([1 / 36 * math.pi, -0.334 * math.pi]), + ), + ], +) +def test_bound_to_180(angle, isDegrees, expected_output): + actual_output = utils.bound_to_180(angle, isDegrees) + assert np.isclose(actual_output, expected_output).all() + + +@pytest.mark.parametrize( + "angle, isDegrees, expected_output", + [ + # Degree tests + (0, True, 0), + (360, True, 0), + (-270, True, 90), + (750.2, True, 30.2), + ( + np.array([754.4, -540, 899, 719.99, 5, -30.74]), + True, + np.array([34.4, 180, 179, 359.99, 5, 329.26]), + ), + # Radian tests + (0, False, 0), + (2 * math.pi, False, 0), + (1.5 * math.pi, False, 1.5 * math.pi), + (-2.5 * math.pi, False, 1.5 * math.pi), + (np.array([3 * math.pi, -3 * math.pi]), False, np.array([math.pi, math.pi])), + ( + np.array([4.44 * math.pi, -5.68 * math.pi]), + False, + np.array([0.44 * math.pi, 0.32 * math.pi]), + ), + ( + np.array([1 / 36 * math.pi, -0.334 * math.pi]), + False, + np.array([1 / 36 * math.pi, 1.666 * math.pi]), + ), + ], +) +def test_bound_to_360(angle, isDegrees, expected_output): + actual_output = utils.bound_to_360(angle, isDegrees) + assert np.isclose(actual_output, expected_output).all() diff --git a/src/boat_simulator/tests/unit/common/test_wind_sensor.py b/src/boat_simulator/tests/unit/common/test_wind_sensor.py new file mode 100644 index 000000000..aed0d1c88 --- /dev/null +++ b/src/boat_simulator/tests/unit/common/test_wind_sensor.py @@ -0,0 +1,77 @@ +from boat_simulator.common.sensors import WindSensor +import numpy as np +from boat_simulator.common.generators import ( + MVGaussianGenerator, + ConstantGenerator, +) + + +class TestWindSensor: + def test_wind_sensor_init(self): + init_data = np.array([1, 0]) + error_fn = None + ws = WindSensor( + wind=init_data, + wind_noisemaker=error_fn, + ) + + assert ws.wind_noisemaker == error_fn + assert np.all(ws.wind == init_data) + + def test_wind_sensor_init_implicit_error_fn(self): + init_data = np.array([1, 0]) + ws = WindSensor(wind=init_data) + + assert ws.wind_noisemaker is None + assert np.all(ws.wind == init_data) + + def test_wind_sensor_read_no_error(self): + init_data = np.array([1, 0]) + ws = WindSensor( + wind=init_data, + ) + read_data = ws.read("wind") + assert (init_data == read_data).all() + + def test_wind_sensor_read_constant_error(self): + init_data = np.array([1, 0]) + const_err = 0.1 + error_fn = ConstantGenerator(constant=0.1) + ws = WindSensor( + wind=init_data, + wind_noisemaker=error_fn, + ) + + read_data = ws.read("wind") + assert ((init_data + const_err) == read_data).all() + + def test_wind_sensor_read_mv_gaussian_error(self): + init_data = np.array([1, 0]) + mean = np.array([1, 1]) + cov = np.eye(2) + error_fn = MVGaussianGenerator(mean=mean, cov=cov) + ws = WindSensor( + wind=init_data, + wind_noisemaker=error_fn, + ) + + NUM_READINGS = 10000 + reading = np.zeros(shape=(NUM_READINGS, mean.size)) + for i in range(NUM_READINGS): + reading[i, :] = ws.read("wind") + + sample_mean = np.mean(reading, axis=0) + sample_cov = np.cov(reading, rowvar=False) + + assert np.allclose(sample_cov, cov, atol=0.2) + assert np.isclose(sample_mean, mean + init_data, 0.1).all() + + def test_wind_sensor_update(self): + init_data = np.zeros(2) + ws = WindSensor(wind=init_data) + + NUM_READINGS = 100 + for i in range(NUM_READINGS): + wind = ws.read("wind") + assert (wind == np.array([i, i])).all() + ws.update(wind=(wind + 1)) diff --git a/src/boat_simulator/tests/unit/nodes/__init__.py b/src/boat_simulator/tests/unit/nodes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/unit/nodes/low_level_control/__init__.py b/src/boat_simulator/tests/unit/nodes/low_level_control/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/unit/nodes/low_level_control/test_control.py b/src/boat_simulator/tests/unit/nodes/low_level_control/test_control.py new file mode 100644 index 000000000..4b8257fe0 --- /dev/null +++ b/src/boat_simulator/tests/unit/nodes/low_level_control/test_control.py @@ -0,0 +1,5 @@ +"""Tests classes and functions in boat_simulator/nodes/low_level_control/control.py""" + + +class TestRudderPID: + pass diff --git a/src/boat_simulator/tests/unit/nodes/physics_engine/__init__.py b/src/boat_simulator/tests/unit/nodes/physics_engine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/boat_simulator/tests/unit/nodes/physics_engine/test_fluids.py b/src/boat_simulator/tests/unit/nodes/physics_engine/test_fluids.py new file mode 100644 index 000000000..28277d0c9 --- /dev/null +++ b/src/boat_simulator/tests/unit/nodes/physics_engine/test_fluids.py @@ -0,0 +1,9 @@ +"""Tests classes and functions in boat_simulator/nodes/physics_engine/fluids.py""" + + +class TestFluidGenerator: + pass + + +class TestWindGenerator: + pass diff --git a/src/boat_simulator/tests/unit/nodes/physics_engine/test_kinematics_computation.py b/src/boat_simulator/tests/unit/nodes/physics_engine/test_kinematics_computation.py new file mode 100644 index 000000000..bfd330faf --- /dev/null +++ b/src/boat_simulator/tests/unit/nodes/physics_engine/test_kinematics_computation.py @@ -0,0 +1,222 @@ +"""Tests classes and functions in boat_simulator/nodes/physics_engine/kinematics_computation.py""" + +from dataclasses import dataclass, field +from typing import Tuple + +import numpy as np +import pytest +from numpy.typing import NDArray + +import boat_simulator.common.constants as constants +import boat_simulator.common.utils as utils +from boat_simulator.common.types import Scalar +from boat_simulator.nodes.physics_engine.kinematics_computation import BoatKinematics +from boat_simulator.nodes.physics_engine.kinematics_data import KinematicsData +from boat_simulator.nodes.physics_engine.kinematics_formulas import KinematicsFormulas + + +@dataclass +class ExpectedData: + """Stores expected kinematic data, including position, velocity, and acceleration.""" + + position: NDArray = field(default=np.zeros(3, dtype=np.float32)) + velocity: NDArray = field(default=np.zeros(3, dtype=np.float32)) + acceleration: NDArray = field(default=np.zeros(3, dtype=np.float32)) + + +class TestKinematicsComputation: + def __test_ang_kinematics( + self, + timestep: Scalar, + inertia_inverse: NDArray, + net_torque: NDArray, + prev_expected_data: ExpectedData, + relative_data: KinematicsData, + global_data: KinematicsData, + ) -> Tuple[ExpectedData, Scalar]: + """Verifies that the actual angular kinematic data matches the expected values and computes + the next global angular position in radians. + + Returns: + Tuple[ExpectedData, Scalar]: A tuple where the first element represents an updated + `prev_expected_data` with kinematic data from this step, using SI units. The second + element represents the yaw angle in radians (rad) for the next global angular + position. + """ + expected_ang_acc = KinematicsFormulas.next_ang_acceleration(net_torque, inertia_inverse) + + assert np.isclose(relative_data.angular_acceleration, expected_ang_acc).all() + assert np.isclose(global_data.angular_acceleration, expected_ang_acc).all() + + expected_ang_vel = KinematicsFormulas.next_velocity( + prev_expected_data.velocity, + prev_expected_data.acceleration, + timestep, + ) + + assert np.isclose(relative_data.angular_velocity, expected_ang_vel).all() + assert np.isclose(global_data.angular_velocity, expected_ang_vel).all() + + expected_ang_pos = utils.bound_to_180( + KinematicsFormulas.next_position( + prev_expected_data.position, + prev_expected_data.velocity, + prev_expected_data.acceleration, + timestep, + ), + isDegrees=False, + ) + assert np.isclose( + relative_data.angular_position, np.array([0, 0, 0], dtype=np.float32) + ).all() # relative angular position is unused + assert np.isclose(global_data.angular_position, expected_ang_pos).all() + + # update previous expected data + prev_expected_data.acceleration = expected_ang_acc + prev_expected_data.velocity = expected_ang_vel + prev_expected_data.position = expected_ang_pos + + yaw_radians: Scalar = expected_ang_pos[constants.ORIENTATION_INDICES.YAW.value] + return (prev_expected_data, yaw_radians) + + def __test_rel_lin_kinematics( + self, + timestep: Scalar, + mass: Scalar, + net_force: NDArray, + prev_expected_data: ExpectedData, + actual_data: KinematicsData, + ) -> ExpectedData: + """Verifies that the actual relative linear kinematic data matches the expected values. + + Returns: + ExpectedData: An updated `prev_expected_data` with kinematic data from this step, using + SI units. + """ + expected_rel_lin_acc = KinematicsFormulas.next_lin_acceleration(mass, net_force) + assert np.isclose(actual_data.linear_acceleration, expected_rel_lin_acc).all() + + expected_rel_lin_vel = KinematicsFormulas.next_velocity( + prev_expected_data.velocity, + prev_expected_data.acceleration, + timestep, + ) + assert np.isclose(actual_data.linear_velocity, expected_rel_lin_vel).all() + + expected_rel_lin_pos = KinematicsFormulas.next_position( + prev_expected_data.position, + prev_expected_data.velocity, + prev_expected_data.acceleration, + timestep, + ) + assert np.isclose( + actual_data.linear_position, np.array([0, 0, 0], dtype=np.float32) + ).all() # relative linear position is unused + + # update previous expected data + prev_expected_data.acceleration = expected_rel_lin_acc + prev_expected_data.velocity = expected_rel_lin_vel + prev_expected_data.position = expected_rel_lin_pos + + return prev_expected_data + + def __test_glo_lin_kinematics( + self, + timestep: Scalar, + mass: Scalar, + net_force: NDArray, + prev_expected_data: ExpectedData, + actual_data: KinematicsData, + ) -> ExpectedData: + """Verifies that the actual global linear kinematic data matches the expected values. + + Returns: + ExpectedData: An updated `prev_expected_data` with kinematic data from this step, + using SI units. + """ + expected_glo_lin_acc = KinematicsFormulas.next_lin_acceleration(mass, net_force) + assert np.isclose(actual_data.linear_acceleration, expected_glo_lin_acc).all() + + expected_glo_lin_vel = KinematicsFormulas.next_velocity( + prev_expected_data.velocity, + prev_expected_data.acceleration, + timestep, + ) + assert np.isclose(actual_data.linear_velocity, expected_glo_lin_vel).all() + + expected_glo_lin_pos = KinematicsFormulas.next_position( + prev_expected_data.position, + prev_expected_data.velocity, + prev_expected_data.acceleration, + timestep, + ) + assert np.isclose(actual_data.linear_position, expected_glo_lin_pos).all() + + # update previous expected data + prev_expected_data.acceleration = expected_glo_lin_acc + prev_expected_data.velocity = expected_glo_lin_vel + prev_expected_data.position = expected_glo_lin_pos + + return prev_expected_data + + @pytest.mark.parametrize( + "timestep, mass, inertia, rel_net_force, net_torque, num_steps", + [ + ( + 0.1, + 10.0, + np.array([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]], dtype=np.float32), + np.array([1.0, 2.0, 3.0], dtype=np.float32), + np.array([0.1, 0.2, 0.3], dtype=np.float32), + 3, + ), + ( + 0.05, + 15.0, + np.array([[0.5, 0.5, 0.5], [0.0, 0.5, 0.5], [0.0, 0.0, 0.5]], dtype=np.float32), + np.array([2.0, 3.0, 1.0], dtype=np.float32), + np.array([0.2, 0.3, 0.1], dtype=np.float32), + 3, + ), + ], + ) + def test_boat_kinematics(self, timestep, mass, inertia, rel_net_force, net_torque, num_steps): + """Test the boat kinematics simulation by iterating through multiple time steps + and verifying the computed kinematic data.""" + + inertia_inverse = np.linalg.inv(inertia) + boat_kinematics = BoatKinematics(timestep, mass, inertia) + + prev_expected_ang_data = ExpectedData() + prev_expected_rel_lin_data = ExpectedData() + prev_expected_glo_lin_data = ExpectedData() + + for _ in range(num_steps): + relative_data, global_data = boat_kinematics.step(rel_net_force, net_torque) + + prev_expected_ang_data, yaw_radians = self.__test_ang_kinematics( + timestep, + inertia_inverse, + net_torque, + prev_expected_ang_data, + relative_data, + global_data, + ) + + prev_expected_rel_lin_data = self.__test_rel_lin_kinematics( + timestep, + mass, + rel_net_force, + prev_expected_rel_lin_data, + relative_data, + ) + + # z-directional acceleration and velocity are neglected + glo_net_force = rel_net_force * np.array([np.cos(yaw_radians), np.sin(yaw_radians), 0]) + prev_expected_glo_lin_data = self.__test_glo_lin_kinematics( + timestep, + mass, + glo_net_force, + prev_expected_glo_lin_data, + global_data, + ) diff --git a/src/boat_simulator/tests/unit/nodes/physics_engine/test_model.py b/src/boat_simulator/tests/unit/nodes/physics_engine/test_model.py new file mode 100644 index 000000000..98407fc79 --- /dev/null +++ b/src/boat_simulator/tests/unit/nodes/physics_engine/test_model.py @@ -0,0 +1,5 @@ +"""Tests classes and functions in boat_simulator/nodes/physics_engine/model.py""" + + +class TestBoatState: + pass diff --git a/src/boat_simulator/tests/unit/nodes/physics_engine/test_output_interface.py b/src/boat_simulator/tests/unit/nodes/physics_engine/test_output_interface.py new file mode 100644 index 000000000..5cadb8ae6 --- /dev/null +++ b/src/boat_simulator/tests/unit/nodes/physics_engine/test_output_interface.py @@ -0,0 +1,13 @@ +"""Tests classes and functions in boat_simulator/nodes/physics_engine/output_interface.py""" + + +class TestOutputNoise: + pass + + +class TestOutputDelay: + pass + + +class OutputInterface: + pass diff --git a/src/controller/.gitignore b/src/controller/.gitignore new file mode 100644 index 000000000..0af0477bb --- /dev/null +++ b/src/controller/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Generated directories from ROS +build/* +install/* +log/* diff --git a/src/controller/LICENSE b/src/controller/LICENSE new file mode 100644 index 000000000..ef10cf196 --- /dev/null +++ b/src/controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 UBC Sailbot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/controller/README.md b/src/controller/README.md new file mode 100644 index 000000000..05c831a2c --- /dev/null +++ b/src/controller/README.md @@ -0,0 +1,31 @@ +# Controller + +[![Tests](https://github.com/UBCSailbot/controller/actions/workflows/tests.yml/badge.svg)](https://github.com/UBCSailbot/controller/actions/workflows/tests.yml) + +UBC Sailbot's controller for the new project. This repository contains a ROS package `controller`. This README +contains only setup and run instructions. Further information on the controller can be found on the software +team's [docs website](https://ubcsailbot.github.io/sailbot_workspace/main/current/controller/overview/). + +## Setup + +The controller is meant to be ran inside the [Sailbot Workspace](https://github.com/UBCSailbot/sailbot_workspace) +development environment. Follow the setup instructions for the Sailbot Workspace +[here](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/setup/) +to get started and build all the necessary ROS packages. + +## Run + +The [`launch/`](./launch/) folder contains a [ROS 2 launch file](https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/Launch-Main.html) +responsible for starting up the controller. To run the controller standalone, execute the launch file after building +the `controller` package: + +``` shell +ros2 launch controller main_launch.py [OPTIONS]... +``` + +To see a list of options for configuration, add the `-s` flag at the end of the above command. + +## Test + +Run the `test` task in the Sailbot Workspace. See [here](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) +on how to run vscode tasks. diff --git a/src/controller/controller/__init__.py b/src/controller/controller/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/controller/controller/common/__init__.py b/src/controller/controller/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/controller/controller/common/constants.py b/src/controller/controller/common/constants.py new file mode 100644 index 000000000..3dc5c4f0b --- /dev/null +++ b/src/controller/controller/common/constants.py @@ -0,0 +1 @@ +"""Constants used across the controller package.""" diff --git a/src/controller/controller/common/lut.py b/src/controller/controller/common/lut.py new file mode 100644 index 000000000..c7a0d24dd --- /dev/null +++ b/src/controller/controller/common/lut.py @@ -0,0 +1,84 @@ +from typing import List + +import numpy as np +from numpy.typing import NDArray +from scipy import interpolate + +from controller.common.types import Scalar + + +class LUT: + """ + Class for performing look-up table interpolation. + + Methods: + __init__: Initializes the LUT object with lookup table data and interpolation method. + __call__: Calls the interpolation method with the given input. + """ + + def __init__( + self, + lookup_table: List[List[Scalar]] | NDArray, + interpolation_method: str = "linear", + ): + """ + Initializes the LUT object. + + Args: + lookup_table (List[List[Scalar]] or NDArray): A list of lists or NDArray containing x-y + data points for interpolation. Shape should be (n, 2) + interpolation_method (str): Interpolation method to use. Default is "linear". + + Raises: + ValueError: If the specified interpolation method is unknown + or if the table shape is incorrect. + """ + if isinstance(lookup_table, np.ndarray): + table = lookup_table + else: + table = np.array(lookup_table) + + self.__verifyTable(table) + self.x = table[:, 0] + self.y = table[:, 1] + + self.__interpolation_method = interpolation_method + + match self.__interpolation_method: + case "linear": + self.__interpolation_function = self.__linearInterpolation + case "spline": + self.__interpolation_function = self.__splineInterpolation + case _: + raise ValueError( + self.__interpolation_method + " is an unknown interpolation method!" + ) + + def __call__(self, x: float) -> float: + """ + Calls the interpolation method with the given input. + + Args: + x (float): The input value to interpolate. + + Returns: + float: The interpolated value using the interpolation method defined when LUT instance + creation. + """ + return self.__interpolation_function(x) + + def __linearInterpolation(self, x: Scalar) -> float: + output = np.interp(x, self.x, self.y) + if isinstance(output, np.ndarray): + raise ValueError( + "linear interpolation returned a NDArray when it should have returned a float" + ) + return output + + def __splineInterpolation(self, x: Scalar) -> float: + cs = interpolate.CubicSpline(self.x, self.y) + return cs(x) + + def __verifyTable(self, table: NDArray) -> None: + if (len(table.shape) != 2) or table.shape[1] != 2: + raise ValueError(f"Input table has shape {table.shape}, but expected shape of (n, 2)") diff --git a/src/controller/controller/common/types.py b/src/controller/controller/common/types.py new file mode 100644 index 000000000..4ee6f786d --- /dev/null +++ b/src/controller/controller/common/types.py @@ -0,0 +1,12 @@ +"""Custom types used for type hinting in the controller package.""" + +from typing import Union + +import numpy as np +from numpy.typing import NDArray + +# Scalar value that can be an integer or a float +Scalar = Union[int, float] + +# Used in cases where support for scalars or arrays of scalars are needed. +ScalarOrArray = Union[Scalar, NDArray[Union[np.int32, np.float32]]] diff --git a/src/controller/controller/wingsail/__init__.py b/src/controller/controller/wingsail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/controller/controller/wingsail/wingsail_ctrl_node.py b/src/controller/controller/wingsail/wingsail_ctrl_node.py new file mode 100644 index 000000000..17345135d --- /dev/null +++ b/src/controller/controller/wingsail/wingsail_ctrl_node.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + +"""The ROS node for the wingsail controller.""" + +import rclpy +import rclpy.utilities +from custom_interfaces.msg import GPS, SailCmd, WindSensor +from rclpy.node import Node + + +def main(args=None): + rclpy.init(args=args) + node = WingsailControllerNode() + rclpy.spin(node=node) + node.destroy_node() + rclpy.shutdown() + + +class WingsailControllerNode(Node): + """ + A ROS node that controls the trim tab angle wingsail of the boat. The objective + of the wingsail controller is to maintain the wingsail at a desired angle of attack + while optimizing for speed by maximizing the lift-to-drag ratio of the wingsail. + + Subscriptions: + __filtered_wind_sensors_sub (Subscription): Subscribes to the filtered_wind_sensor topic + __gps_sub (Subscription): Subscribes to the gps topic + + Publishers: + TO BE ADDED + """ + + def __init__(self): + """Initializes an instance of this class.""" + super().__init__("wingsail_ctrl_node") + + self.get_logger().debug("Initializing node...") + self.__init_private_attributes() + self.__declare_ros_parameters() + self.__init_subscriptions() + self.__init_publishers() + self.__init_timer_callbacks() + self.get_logger().debug("Node initialization complete. Starting execution...") + + def __init_private_attributes(self): + """Initializes private attributes of this class that are not initialized anywhere else + during the initialization process. + """ + self.__trim_tab_angle = 0.0 + self.__filtered_wind_sensor = None + self.__gps = None + + def __declare_ros_parameters(self): + """Declares ROS parameters from the global configuration file that will be used in this + node. This node will monitor for any changes to these parameters during execution and will + update itself accordingly. + """ + self.get_logger().debug("Declaring ROS parameters...") + self.declare_parameters( + namespace="", + parameters=[ + ("pub_period_sec", rclpy.Parameter.Type.DOUBLE), + ("reynolds_number", rclpy.Parameter.Type.DOUBLE_ARRAY), + ("angle_of_attack", rclpy.Parameter.Type.DOUBLE_ARRAY), + ], + ) + + # TODO Revisit this debug statement. It might get ugly for args with complicated structures + all_parameters = self._parameters + for name, parameter in all_parameters.items(): + value_str = str(parameter.value) + self.get_logger().debug(f"Got parameter {name} with value {value_str}") + + def __init_subscriptions(self): + """Initializes the subscriptions of this node. Subscriptions pull data from other ROS + topics for further usage in this node. Data is pulled from subscriptions periodically via + callbacks, which are registered upon subscription initialization. + """ + # TODO Implement this function by subscribing to topics that give the desired input data + # Callbacks for each subscriptions should be defined as private methods of this class + self.get_logger().debug("Initializing subscriptions...") + + self.__filtered_wind_sensor_sub = self.create_subscription( + msg_type=WindSensor, + topic="filtered_wind_sensor", + callback=self.__filtered_wind_sensor_sub_callback, + qos_profile=1, + ) + + self.__gps_sub = self.create_subscription( + msg_type=GPS, + topic="gps", + callback=self.__gps_sub_callback, + qos_profile=1, + ) + + def __init_publishers(self): + """Initializes the publishers of this node. Publishers update ROS topics so that other ROS + nodes in the system can utilize the data produced by this node. + """ + + self.get_logger().debug("Initializing publishers...") + self.__trim_tab_angle_pub = self.create_publisher( + msg_type=SailCmd, + # TODO change "topic" from a magic string to a constant similar to how its done in the + # boat simulator + topic="sail_cmd", + qos_profile=1, + ) + + def __init_timer_callbacks(self): + """Initializes the timer callbacks of this node. Timer callbacks are registered to be + called at the specified frequency.""" + + self.get_logger().debug("Initializing timer callbacks...") + + # Publishing data to ROS topics + self.create_timer( + timer_period_sec=self.pub_period, + callback=self.__publish, + ) + + # PUBLISHER CALLBACKS + def __publish(self): + """Publishes a SailCmd message with the trim tab angle using the designated publisher. + It also logs information about the publication to the logger.""" + + msg = SailCmd() + msg.trim_tab_angle_degrees = 0.0 + self.__trim_tab_angle_pub.publish(msg) + self.get_logger().info(f"Published to {self.__trim_tab_angle_pub.topic}") + + @property + def pub_period(self) -> float: + return self.get_parameter("pub_period_sec").get_parameter_value().double_value + + @property + def trim_tab_angle(self) -> float: + return self.__trim_tab_angle + + def __filtered_wind_sensor_sub_callback(self, msg: WindSensor) -> None: + """Stores the latest filtered wind sensor data + + Args: + msg (WindSensor): Filtered wind sensor data from CanTrxRosIntf. + """ + self.__filtered_wind_sensor = msg + self.get_logger().info(f"Received data from {self.__filtered_wind_sensor_sub.topic}") + + def __gps_sub_callback(self, msg: GPS) -> None: + """Stores the latest gps data + + Args: + msg (GPS): gps data from CanTrxRosIntf. + """ + self.__gps = msg + self.get_logger().info(f"Received data from {self.__gps_sub.topic}") + + +if __name__ == "__main__": + main() diff --git a/src/controller/launch/main_launch.py b/src/controller/launch/main_launch.py new file mode 100644 index 000000000..cf11545c8 --- /dev/null +++ b/src/controller/launch/main_launch.py @@ -0,0 +1,99 @@ +"""Launch file that runs all nodes for the controller ROS package.""" + +import importlib +import os +from typing import List, Tuple + +from launch_ros.actions import Node + +from launch.actions import DeclareLaunchArgument, OpaqueFunction +from launch.launch_context import LaunchContext +from launch.launch_description import LaunchDescription +from launch.some_substitutions_type import SomeSubstitutionsType +from launch.substitutions import LaunchConfiguration + +# Local launch arguments and constants +PACKAGE_NAME = "controller" + +# Add args with DeclareLaunchArguments object(s) and utilize in setup_launch() +LOCAL_LAUNCH_ARGUMENTS: List[DeclareLaunchArgument] = [] + + +def generate_launch_description() -> LaunchDescription: + """The launch file entry point. Generates the launch description for the `controller` + package. + + Returns: + LaunchDescription: The launch description. + """ + global_launch_arguments, global_environment_vars = get_global_launch_arguments() + return LaunchDescription( + [ + *global_launch_arguments, + *global_environment_vars, + *LOCAL_LAUNCH_ARGUMENTS, + OpaqueFunction(function=setup_launch), + ] + ) + + +def get_global_launch_arguments() -> Tuple: + """Gets the global launch arguments and environment variables from the global launch file. + + Returns: + Tuple: The global launch arguments and environment variables. + """ + ros_workspace = os.getenv("ROS_WORKSPACE", default="/workspaces/sailbot_workspace") + global_main_launch = os.path.join(ros_workspace, "src", "global_launch", "main_launch.py") + spec = importlib.util.spec_from_file_location("global_launch", global_main_launch) + if spec is None: + raise ImportError(f"Couldn't import global_launch module from {global_main_launch}") + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] # spec is not None + spec.loader.exec_module(module) # type: ignore[union-attr] # spec is not None + global_launch_arguments = module.GLOBAL_LAUNCH_ARGUMENTS + global_environment_vars = module.ENVIRONMENT_VARIABLES + return global_launch_arguments, global_environment_vars + + +def setup_launch(context: LaunchContext) -> List[Node]: + """Collects launch descriptions that describe the system behavior in the `controller` + package. + + Args: + context (LaunchContext): The current launch context. + + Returns: + List[Node]: Nodes to launch. + """ + launch_description_entities = list() + launch_description_entities.append(get_wingsail_controller_description(context)) + return launch_description_entities + + +def get_wingsail_controller_description(context: LaunchContext) -> Node: + """Gets the launch description for the wingsail controller node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the wingsail controller node. + """ + node_name = "wingsail_ctrl_node" + ros_parameters = [LaunchConfiguration("config").perform(context)] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + local_arguments: List[SomeSubstitutionsType] = [] + + node = Node( + package=PACKAGE_NAME, + executable=node_name, + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + arguments=local_arguments, + ) + + return node diff --git a/src/controller/package.xml b/src/controller/package.xml new file mode 100644 index 000000000..8283a3245 --- /dev/null +++ b/src/controller/package.xml @@ -0,0 +1,26 @@ + + + + controller + 0.0.0 + UBC Sailbot's Controller + Devon Friend + MIT + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + custom_interfaces + ros2launch + + + python3-numpy + python3-scipy + + + ament_python + + diff --git a/src/controller/resource/controller b/src/controller/resource/controller new file mode 100644 index 000000000..e69de29bb diff --git a/src/controller/setup.cfg b/src/controller/setup.cfg new file mode 100644 index 000000000..1049f8af5 --- /dev/null +++ b/src/controller/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/controller +[install] +install_scripts=$base/lib/controller diff --git a/src/controller/setup.py b/src/controller/setup.py new file mode 100644 index 000000000..f6bc1fbc5 --- /dev/null +++ b/src/controller/setup.py @@ -0,0 +1,30 @@ +from glob import glob +from os.path import join + +from setuptools import find_packages, setup + +PACKAGE_NAME = "controller" +REQUIRED_MODULES = ["setuptools", "numpy", "scipy"] + +setup( + name=PACKAGE_NAME, + version="0.0.0", + packages=find_packages(exclude=["tests"]), + install_requires=REQUIRED_MODULES, + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + PACKAGE_NAME]), + ("share/" + PACKAGE_NAME, ["package.xml"]), + (join("share", PACKAGE_NAME), glob("launch/*_launch.py")), + ], + zip_safe=True, + maintainer="Devon Friend", + maintainer_email="software@ubcsailbot.org", + description="UBC Sailbot's Controller", + license="MIT", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "wingsail_ctrl_node = controller.wingsail.wingsail_ctrl_node:main", + ], + }, +) diff --git a/src/controller/tests/integration/.gitkeep b/src/controller/tests/integration/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/controller/tests/unit/wingsail/.gitkeep b/src/controller/tests/unit/wingsail/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/controller/tests/unit/wingsail/common/test_lut.py b/src/controller/tests/unit/wingsail/common/test_lut.py new file mode 100644 index 000000000..dc7c1d907 --- /dev/null +++ b/src/controller/tests/unit/wingsail/common/test_lut.py @@ -0,0 +1,88 @@ +import math + +import numpy as np +import pytest +from scipy import interpolate + +from controller.common.lut import LUT + + +class TestLUT: + # Intialize lookup table + look_up_table = [[50000, 5.75], [100000, 6.75], [200000, 7], [500000, 9.75], [1000000, 10]] + look_up_np_array = np.array(look_up_table) + + def test_LUT_constructor(self): + # set up + testLUT = LUT(self.look_up_table) + testLUT2 = LUT(self.look_up_np_array) + # test that LUT return a known value + assert math.isclose(testLUT(50000), 5.75) + assert math.isclose(testLUT2(50000), 5.75) + + def test_unknown_interpolation_exception(self): + with pytest.raises(ValueError): + testLUT = LUT(self.look_up_table, "gabagool") + assert math.isclose(testLUT(50000), 5.75) + + @pytest.mark.parametrize( + "invalid_table", + [ + [[10000, 10000, 10000], [1, 1, 1]], + [10000, 10000, 10000], + [[0, 1], 10000, 10000], + np.array([[10000, 10000, 10000], [1, 1, 1]]), + np.array([10000, 10000, 10000]), + np.array([[[0, 1]], [[0, 1]], [[0, 1]]]), + ], + ) + def test_invalid_table_exception(self, invalid_table): + with pytest.raises(ValueError): + testLUT = LUT(invalid_table) + assert math.isclose(testLUT(50000), 5.75) + + @pytest.mark.parametrize( + "invalid_table", + [ + np.array([[10000, 10000, 10000], [1, 1, 1]]), + np.array([10000, 10000, 10000]), + np.array([[[0, 1]], [[0, 1]], [[0, 1]]]), + ], + ) + def test_invalid_numpy_array_exception(self, invalid_table): + with pytest.raises(ValueError): + testLUT = LUT(invalid_table) + assert math.isclose(testLUT(50000), 5.75) + + @pytest.mark.parametrize("linear_test_values", list(range(50000, 1100000, 10000))) + def test_linear_interpolation(self, linear_test_values): + # set up + testLUT = LUT(self.look_up_table) + table = np.array(self.look_up_table) + + # Test that LUT returns same values as np linear interpolate function + assert math.isclose( + testLUT(linear_test_values), np.interp(linear_test_values, table[:, 0], table[:, 1]) + ) + + @pytest.mark.parametrize("test_value, expected_value", [(1000, 5.75), (2000000, 10)]) + def test_linear_extrapolation(self, test_value, expected_value): + testLUT = LUT(self.look_up_table) + # Test that linear interpolation does not extrapolate + assert math.isclose(testLUT(test_value), expected_value) + + @pytest.mark.parametrize("test_value", [[100, 200, 300]]) + def test_linear_interpolation_exception(self, test_value): + testLUT = LUT(self.look_up_table) + # Test that linear interpolation does not extrapolate + with pytest.raises(ValueError): + testLUT(test_value) + + @pytest.mark.parametrize("spline_test_values", list(range(10000, 2100000, 10000))) + def test_spline_interpolation_extrapolation(self, spline_test_values): + testLUT = LUT(self.look_up_table, "spline") + table = np.array(self.look_up_table) + cs = interpolate.CubicSpline(table[:, 0], table[:, 1]) + + # Test that LUT returns same values as cubic interpolate function + assert math.isclose(testLUT(spline_test_values), cs(spline_test_values)) diff --git a/src/custom_interfaces/.gitignore b/src/custom_interfaces/.gitignore new file mode 100644 index 000000000..f3496c7aa --- /dev/null +++ b/src/custom_interfaces/.gitignore @@ -0,0 +1,3 @@ +# PlantUML export directory +/diagrams/out/*.png +!/diagrams/out/external_interfaces.png diff --git a/src/custom_interfaces/CMakeLists.txt b/src/custom_interfaces/CMakeLists.txt new file mode 100644 index 000000000..2223c0a2d --- /dev/null +++ b/src/custom_interfaces/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.8) +project(custom_interfaces) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# Find dependencies +find_package(ament_cmake REQUIRED) + +# Add more dependencies manually with: find_package( REQUIRED) +find_package(std_msgs REQUIRED) +find_package(geometry_msgs REQUIRED) +find_package(rosidl_default_generators REQUIRED) + +# Common custom messages +set(common_msg + # external + "msg/AISShips.msg" + "msg/Batteries.msg" + "msg/CanSimToBoatSim.msg" + "msg/DesiredHeading.msg" + "msg/GenericSensors.msg" + "msg/GPS.msg" + "msg/LPathData.msg" + "msg/Path.msg" + "msg/SailCmd.msg" + "msg/WindSensor.msg" + "msg/WindSensors.msg" + + # internal + "msg/HelperAISShip.msg" + "msg/HelperBattery.msg" + "msg/HelperDimension.msg" + "msg/HelperGenericSensor.msg" + "msg/HelperHeading.msg" + "msg/HelperLatLon.msg" + "msg/HelperROT.msg" + "msg/HelperSpeed.msg" +) + +# Boat simulator custom messages +set(simulator_msg + # external + "msg/SimWorldState.msg" +) + +# Boat simulator custom actions +set(simulator_action + "action/SimRudderActuation.action" + "action/SimSailTrimTabActuation.action" +) + +rosidl_generate_interfaces(${PROJECT_NAME} + # Add custom messages + ${common_msg} + ${simulator_msg} + ${simulator_action} + + # Add packages that above messages depend on below if needed + DEPENDENCIES std_msgs geometry_msgs +) + +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + # the following line skips the linter which checks for copyrights + # comment the line when a copyright and license is added to all source files + set(ament_cmake_copyright_FOUND TRUE) + # the following line skips cpplint (only works in a git repo) + # comment the line when this package is in a git repo and when + # a copyright and license is added to all source files + set(ament_cmake_cpplint_FOUND TRUE) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/src/custom_interfaces/LICENSE b/src/custom_interfaces/LICENSE new file mode 100644 index 000000000..30e8e2ece --- /dev/null +++ b/src/custom_interfaces/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/custom_interfaces/README.md b/src/custom_interfaces/README.md new file mode 100644 index 000000000..b918e7bb0 --- /dev/null +++ b/src/custom_interfaces/README.md @@ -0,0 +1,72 @@ +# Custom Interfaces + +UBC Sailbot's custom interfaces ROS package. To add `custom_interfaces` to another ROS package, follow the instructions +[here](https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Custom-ROS2-Interfaces.html#test-the-new-interfaces). + +The terminology that we use in this document are the following: + +- **External Interface**: An interface used to communicate data between nodes and ROS packages. +- **Internal Interface**: An interface used to standardize conventions across external interfaces. Standards are +documented in the `.msg` or `.srv` file associated with that interface. + +## Project-wide Interfaces + +ROS messages and services used across many ROS packages in the project. + +### Project-wide External Interfaces + + + +![External Interface Diagram](https://github.com/UBCSailbot/custom_interfaces/blob/main/diagrams/out/external_interfaces.png?raw=true) + +### Project-wide Internal Interfaces + +| Interface | Used In | +| ------------------- | ---------------------------------- | +| HelperAISShip | AISShips | +| HelperBattery | Batteries | +| HelperDimension | HelperAISShip | +| HelperGenericSensor | GenericSensors | +| HelperHeading | DesiredHeading, GPS, HelperAISShip | +| HelperLatLon | GPS, HelperAISShip, Path | +| HelperROT | HelperAISShip | +| HelperSpeed | GPS, HelperAISShip, WindSensor | + +## Boat Simulator Interfaces + +ROS messages and services used in our [boat simulator](https://github.com/UBCSailbot/boat_simulator). + +### Boat Simulator External Interfaces + +| Topic | Type | Publisher | Subscriber(s) | +| ---------------------- | -------------- | ------------------------ | ------------------------------------------- | +| `mock_kinematics` | SimWorldState | Simulator Physics Engine | Simulator Visualizer | + +### Boat Simulator Actions + +| Action | Client Node | Server Node | +| ----------------------- | ------------------------ | ------------------------------ | +| SimRudderActuation | Simulator Physics Engine | Simulator Low Level Controller | +| SimSailTrimTabActuation | Simulator Physics Engine | Simulator Low Level Controller | + +## Resources + +### Common Interfaces + +The ROS2 [common_interfaces](https://github.com/ros2/common_interfaces/tree/humble) repository defines a set of +packages which contain common interface files. Since we are using the Humble version of ROS2, see the `humble` branch. +These interfaces can be used in this repository or as a reference for ideas and best practices. + +| Package | Possible Usage | +| ------------------- | ---------------------------------- | +| diagnostic_msgs | Could be used for website sensors | +| geometry_msgs | Simulator, Local Pathfinding | +| sensor_msgs | Reference for CAN Transceiver | +| std_msgs | Reference | +| std_srvs | Reference | +| visualization_msgs | Reference | + +For more detail on the usefulness of each package, see [this issue comment](https://github.com/UBCSailbot/custom_interfaces/issues/3#issuecomment-1626875658). +If you are interested in creating your own custom message or service, see the [ROS Humble documentation](https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Custom-ROS2-Interfaces.html). diff --git a/src/custom_interfaces/action/SimRudderActuation.action b/src/custom_interfaces/action/SimRudderActuation.action new file mode 100644 index 000000000..2bb0fdbd9 --- /dev/null +++ b/src/custom_interfaces/action/SimRudderActuation.action @@ -0,0 +1,12 @@ +# Request: The desired heading to turn to +DesiredHeading desired_heading +--- +# Result: The absolute angular distance between the current and desired heading +# Units: degrees +# Range: [0, 180] +float32 remaining_angular_distance +--- +# Feedback: The rudder angle with respect to the boat +# Units: degrees, 0° south, increases CW +# Range: [-90, 90] +float32 rudder_angle diff --git a/src/custom_interfaces/action/SimSailTrimTabActuation.action b/src/custom_interfaces/action/SimSailTrimTabActuation.action new file mode 100644 index 000000000..7dc3148dd --- /dev/null +++ b/src/custom_interfaces/action/SimSailTrimTabActuation.action @@ -0,0 +1,14 @@ +# Request: The desired position to turn to +# Units: degrees, 0° south, increases CW +# Range: [-90, 90] (TODO: Confirm desireable range with MECH team) +float32 desired_angular_position +--- +# Result: The absolute angular distance between the current and desired heading +# Units: degrees +# Range: [0, 180] (TODO: Conform desireable range with MECH team) +float32 remaining_angular_distance +--- +# Feedback: The trim tab angle with respect to the wingsail +# Units: degrees, 0° south, increases CW +# Range: [-90, 90] (TODO: Confirm desireable range with MECH team) +float32 current_angular_position diff --git a/src/custom_interfaces/diagrams/out/external_interfaces.png b/src/custom_interfaces/diagrams/out/external_interfaces.png new file mode 100644 index 000000000..5634cb399 Binary files /dev/null and b/src/custom_interfaces/diagrams/out/external_interfaces.png differ diff --git a/src/custom_interfaces/diagrams/src/external_interfaces.puml b/src/custom_interfaces/diagrams/src/external_interfaces.puml new file mode 100644 index 000000000..579af589f --- /dev/null +++ b/src/custom_interfaces/diagrams/src/external_interfaces.puml @@ -0,0 +1,160 @@ +@startuml external_interfaces +Title External Interfaces + +!include %getenv("PLANTUML_TEMPLATE_PATH") + +' Define stereotypes to categorize states +skinparam State { + BackgroundColor { + <> White + <> SlateBlue + <> Green + <> Red + <> MidnightBlue + <> $SAILBOT_DARK_BLUE + <> Purple + } + Font { + Color<> Black + Style<> Bold + } +} + +State Legend<> { + State "Publisher" <> + State "Subscriber" <> + State Topic <> : Type + State "Sim Topic" <> : Type + State Node { + + } + State "Sim Node" as sim_node <> { + + } +} + +' Define topics and sim topics +State ais_ships<> : AISShips +State mock_ais_ships<> : AISShips +State batteries<> : Batteries +State boat_sim_input<> : CanSimToBoatSim +state desired_heading<> : DesiredHeading +State data_sensors<> : GenericSensors +State gps<> : GPS +State mock_gps<> : GPS +State local_path_data<> : LPathData +State global_path<> : Path +State "global_path" as mock_global_path<> : Path +note right of mock_global_path + Note: the global_path topic + used during simulation is + the same topic used + during deployment +end note +State sail_cmd<> : SailCmd +State filtered_wind_sensor<> : WindSensor +State mock_wind_sensors<> : WindSensors +State wind_sensors<> : WindSensors + +' Define nodes and sim nodes +State "Local Pathfinding" as l_path <> { + State "Publish" as l_path_pub <> + State "Subscribe" as l_path_sub <> +} +State "Local Transceiver" as l_trans <> { + State "Publish" as l_trans_pub <> + State "Subscribe" as l_trans_sub <> +} +State "Controller" as ctrl <> { + State "Publish" as ctrl_pub <> + State "Subscribe" as ctrl_sub <> +} +State "Boat Simulator" as sim <> { + State "Publish" as sim_pub <> + State "Subscribe" as sim_sub <> +} + +State "Can Transceiver" as can<> { + State "CanTrxRosIntf" as can_trx <> { + State "Publish" as can_trx_pub <> + State "Subscribe" as can_trx_sub <> + } + + State "CanSimIntf" as can_sim <> { + State "Publish" as can_sim_pub <> + State "Subscribe" as can_sim_sub <> + } +} +note top of can + Note: Can Transceiver is a module that + encompasses and connects CanTrxRosIntf + and CanSimIntf +end note + +State "Mock Ais" as ais <> { + state "Publish" as ais_pub <> +} + +State "Mock Global Path" as g_path <> { + State "Publish" as g_path_pub <> +} + +' Publisher --> Topic + +ais_pub --> mock_ais_ships + +can_trx_pub --> ais_ships +can_trx_pub --> batteries +can_trx_pub --> data_sensors +can_trx_pub --> gps +can_trx_pub --> filtered_wind_sensor +can_trx_pub --> wind_sensors + +can_sim_pub --> boat_sim_input + +ctrl_pub -up-> sail_cmd + +l_path_pub --> desired_heading +l_path_pub --> local_path_data + +l_trans_pub --> global_path + +g_path_pub --> mock_global_path + +sim_pub -left-> mock_gps +sim_pub -left-> mock_wind_sensors + +' Topic --> Subscriber + +ais_ships --> l_path_sub +ais_ships --> l_trans_sub + +batteries --> l_trans_sub + +boat_sim_input --> sim_sub + +desired_heading --> ctrl_sub +desired_heading --> sim_sub + +data_sensors --> l_trans_sub + +global_path --> l_path_sub + +mock_global_path -up-> l_path_sub + +gps --> l_trans_sub +gps --> l_path_sub + +local_path_data -up-> l_trans_sub + +mock_ais_ships --> can_sim_sub + +mock_gps -up-> can_sim_sub + +filtered_wind_sensor --> l_path_sub + +mock_wind_sensors -up-> can_sim_sub + +sail_cmd -up-> can_trx_sub + +wind_sensors --> l_trans_sub diff --git a/src/custom_interfaces/msg/AISShips.msg b/src/custom_interfaces/msg/AISShips.msg new file mode 100644 index 000000000..c94b2438d --- /dev/null +++ b/src/custom_interfaces/msg/AISShips.msg @@ -0,0 +1 @@ +HelperAISShip[] ships diff --git a/src/custom_interfaces/msg/Batteries.msg b/src/custom_interfaces/msg/Batteries.msg new file mode 100644 index 000000000..9cdfad0d2 --- /dev/null +++ b/src/custom_interfaces/msg/Batteries.msg @@ -0,0 +1 @@ +HelperBattery[2] batteries diff --git a/src/custom_interfaces/msg/CanSimToBoatSim.msg b/src/custom_interfaces/msg/CanSimToBoatSim.msg new file mode 100644 index 000000000..de579309e --- /dev/null +++ b/src/custom_interfaces/msg/CanSimToBoatSim.msg @@ -0,0 +1,5 @@ +# Data from the topic /desired_heading +DesiredHeading heading + +# Data from the topic /sail_cmd +SailCmd sail_cmd diff --git a/src/custom_interfaces/msg/DesiredHeading.msg b/src/custom_interfaces/msg/DesiredHeading.msg new file mode 100644 index 000000000..51b44ee68 --- /dev/null +++ b/src/custom_interfaces/msg/DesiredHeading.msg @@ -0,0 +1 @@ +HelperHeading heading diff --git a/src/custom_interfaces/msg/GPS.msg b/src/custom_interfaces/msg/GPS.msg new file mode 100644 index 000000000..34ed2d5b6 --- /dev/null +++ b/src/custom_interfaces/msg/GPS.msg @@ -0,0 +1,3 @@ +HelperLatLon lat_lon +HelperSpeed speed +HelperHeading heading diff --git a/src/custom_interfaces/msg/GenericSensors.msg b/src/custom_interfaces/msg/GenericSensors.msg new file mode 100644 index 000000000..001e8710d --- /dev/null +++ b/src/custom_interfaces/msg/GenericSensors.msg @@ -0,0 +1 @@ +HelperGenericSensor[] generic_sensors diff --git a/src/custom_interfaces/msg/HelperAISShip.msg b/src/custom_interfaces/msg/HelperAISShip.msg new file mode 100644 index 000000000..9091d4fba --- /dev/null +++ b/src/custom_interfaces/msg/HelperAISShip.msg @@ -0,0 +1,10 @@ +# AIS identifier (MMSI) +int32 id +HelperLatLon lat_lon +# The ship's course over ground +HelperHeading cog +# The ship's speed over ground +HelperSpeed sog +HelperROT rot +HelperDimension width +HelperDimension length diff --git a/src/custom_interfaces/msg/HelperBattery.msg b/src/custom_interfaces/msg/HelperBattery.msg new file mode 100644 index 000000000..f25bc0fb1 --- /dev/null +++ b/src/custom_interfaces/msg/HelperBattery.msg @@ -0,0 +1,4 @@ +# Voltage: has a non-zero (TBD) lower bound and a (TBD) upper bound +float32 voltage +# Current: negative value means discharging (powering the boat), positive value means charging +float32 current diff --git a/src/custom_interfaces/msg/HelperDimension.msg b/src/custom_interfaces/msg/HelperDimension.msg new file mode 100644 index 000000000..4beaa0d22 --- /dev/null +++ b/src/custom_interfaces/msg/HelperDimension.msg @@ -0,0 +1,2 @@ +# Unit: meters +float32 dimension diff --git a/src/custom_interfaces/msg/HelperGenericSensor.msg b/src/custom_interfaces/msg/HelperGenericSensor.msg new file mode 100644 index 000000000..609f107c7 --- /dev/null +++ b/src/custom_interfaces/msg/HelperGenericSensor.msg @@ -0,0 +1,4 @@ +# Sensor identifier +uint8 id +# Raw, unparsed, binary data +uint64 data diff --git a/src/custom_interfaces/msg/HelperHeading.msg b/src/custom_interfaces/msg/HelperHeading.msg new file mode 100644 index 000000000..3df712857 --- /dev/null +++ b/src/custom_interfaces/msg/HelperHeading.msg @@ -0,0 +1,4 @@ +# Direction the bow of the boat is pointing towards +# Unit: degrees, 0° north, increases CW +# Range: -180 < heading <= 180 +float32 heading diff --git a/src/custom_interfaces/msg/HelperLatLon.msg b/src/custom_interfaces/msg/HelperLatLon.msg new file mode 100644 index 000000000..14f596bd8 --- /dev/null +++ b/src/custom_interfaces/msg/HelperLatLon.msg @@ -0,0 +1,4 @@ +# Unit: decimal degrees +float32 latitude +# Unit: decimal degrees +float32 longitude diff --git a/src/custom_interfaces/msg/HelperROT.msg b/src/custom_interfaces/msg/HelperROT.msg new file mode 100644 index 000000000..63b108a05 --- /dev/null +++ b/src/custom_interfaces/msg/HelperROT.msg @@ -0,0 +1,8 @@ +# Rate of turn of the ship, negative for CCW (min -126) turning and positive for CW (max +126) turning. +# Special unit: https://documentation.spire.com/ais-fundamentals/understanding-ais-performance-in-high-traffic-zones/ +# = int(4.733 * sqrt(degrees/minute)) +# -126 and +126 indicate CCW/CW at a rate of up to 708 degrees/minute or higher +# -127 and +127 are special values indicates CCW/CW turning of more than 10 degrees/minute (only used if the reporting +# vessel does not have an external ROT indicator device available) +# -128 means no rot info +int8 rot diff --git a/src/custom_interfaces/msg/HelperSpeed.msg b/src/custom_interfaces/msg/HelperSpeed.msg new file mode 100644 index 000000000..aca95cb13 --- /dev/null +++ b/src/custom_interfaces/msg/HelperSpeed.msg @@ -0,0 +1,2 @@ +# Unit: kmph +float32 speed diff --git a/src/custom_interfaces/msg/LPathData.msg b/src/custom_interfaces/msg/LPathData.msg new file mode 100644 index 000000000..db82fb5cd --- /dev/null +++ b/src/custom_interfaces/msg/LPathData.msg @@ -0,0 +1 @@ +Path local_path diff --git a/src/custom_interfaces/msg/Path.msg b/src/custom_interfaces/msg/Path.msg new file mode 100644 index 000000000..b5e950bad --- /dev/null +++ b/src/custom_interfaces/msg/Path.msg @@ -0,0 +1 @@ +HelperLatLon[] waypoints diff --git a/src/custom_interfaces/msg/SailCmd.msg b/src/custom_interfaces/msg/SailCmd.msg new file mode 100644 index 000000000..6977a0fa0 --- /dev/null +++ b/src/custom_interfaces/msg/SailCmd.msg @@ -0,0 +1,4 @@ +# Angle to rotate the trim tab relative to the mailsail +# Unit: degrees, 0° is the neutral position, Increases CW +# Range: -40° <= angle <= 40° +float32 trim_tab_angle_degrees diff --git a/src/custom_interfaces/msg/SimWorldState.msg b/src/custom_interfaces/msg/SimWorldState.msg new file mode 100644 index 000000000..cf092a604 --- /dev/null +++ b/src/custom_interfaces/msg/SimWorldState.msg @@ -0,0 +1,24 @@ +# GPS data of the simulated with respect to the Earth (in the global reference frame). +GPS global_gps + +# Contains both position and orientation (in quaternions, following a East-North-Up convention) +# of the simulated boat with respect to the Earth (in the global reference frame). +# Position Unit: km, distance from the origin +geometry_msgs/Pose global_pose + +# Direction that the wind is going TOWARDS. Note that this is difference from the WindSensor type +# in custom_interfaces, where WindSensor.direction gives us the direction where the wind is coming +# FROM (basically a 180 degree phase shift in the velocity direction). +# +# Contains the linear component of the velocity. +# Linear Velocity Unit: km/hr +geometry_msgs/Vector3 wind_velocity + +# Direction that the current is going TOWARDS. +# +# Contains the linear component of the velocity. +# Linear Velocity Unit: km/hr +geometry_msgs/Vector3 current_velocity + +# Timestamp. Contains time since start in seconds and the frame number. +std_msgs/Header header diff --git a/src/custom_interfaces/msg/WindSensor.msg b/src/custom_interfaces/msg/WindSensor.msg new file mode 100644 index 000000000..84179a898 --- /dev/null +++ b/src/custom_interfaces/msg/WindSensor.msg @@ -0,0 +1,4 @@ +HelperSpeed speed +# Unit: degrees, 0° means the apparent wind is blowing from the bow to the stern of the boat, increase CW +# Range: -180 < direction <= 180 for symmetry +int16 direction diff --git a/src/custom_interfaces/msg/WindSensors.msg b/src/custom_interfaces/msg/WindSensors.msg new file mode 100644 index 000000000..b580ca324 --- /dev/null +++ b/src/custom_interfaces/msg/WindSensors.msg @@ -0,0 +1 @@ +WindSensor[2] wind_sensors diff --git a/src/custom_interfaces/package.xml b/src/custom_interfaces/package.xml new file mode 100644 index 000000000..c8bc92dd4 --- /dev/null +++ b/src/custom_interfaces/package.xml @@ -0,0 +1,28 @@ + + + + custom_interfaces + 0.0.0 + UBC Sailbot's custom interfaces ROS package + Patrick Creighton + MIT + + + ament_cmake + + ament_lint_auto + ament_lint_common + + action_msgs + geometry_msgs + std_msgs + + rosidl_default_generators + rosidl_default_runtime + rosidl_interface_packages + + + ament_cmake + + + diff --git a/src/diagnostics/.gitignore b/src/diagnostics/.gitignore new file mode 100644 index 000000000..02e56b2f6 --- /dev/null +++ b/src/diagnostics/.gitignore @@ -0,0 +1,3 @@ +build/ +log/ +install/diagnostics/ diff --git a/src/diagnostics/CMakeLists.txt b/src/diagnostics/CMakeLists.txt new file mode 100644 index 000000000..b654802c3 --- /dev/null +++ b/src/diagnostics/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.8) +project(diagnostics) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# find dependencies +find_package(ament_cmake REQUIRED) +find_package(ryml REQUIRED) + + +add_subdirectory(src) +target_include_directories(diagnostics PUBLIC + $ + $) +target_compile_features(diagnostics PUBLIC c_std_99 cxx_std_17) # Require C99 and C++17 + +install(TARGETS diagnostics + DESTINATION lib/${PROJECT_NAME}) + +ament_package() diff --git a/src/diagnostics/README.md b/src/diagnostics/README.md new file mode 100644 index 000000000..cbe94792d --- /dev/null +++ b/src/diagnostics/README.md @@ -0,0 +1,4 @@ +# diagnostics + +Standalone on-boat application that will provide real-time status reporting and allow users to run tests on each component +of the boat both comprehensively and individually. diff --git a/src/diagnostics/package.xml b/src/diagnostics/package.xml new file mode 100644 index 000000000..a561f12f0 --- /dev/null +++ b/src/diagnostics/package.xml @@ -0,0 +1,18 @@ + + + + diagnostics + 0.0.0 + TODO: Package description + ros + TODO: License declaration + + ament_cmake + + ament_lint_auto + ament_lint_common + + + ament_cmake + + diff --git a/src/diagnostics/src/CMakeLists.txt b/src/diagnostics/src/CMakeLists.txt new file mode 100644 index 000000000..7b19b6ca5 --- /dev/null +++ b/src/diagnostics/src/CMakeLists.txt @@ -0,0 +1,3 @@ +add_subdirectory(main) +add_subdirectory(ui) +add_subdirectory(boatTest) diff --git a/src/diagnostics/src/boatTest/CMakeLists.txt b/src/diagnostics/src/boatTest/CMakeLists.txt new file mode 100644 index 000000000..f6f423b5a --- /dev/null +++ b/src/diagnostics/src/boatTest/CMakeLists.txt @@ -0,0 +1,3 @@ +add_subdirectory(common) +add_subdirectory(config) +add_subdirectory(parse_yaml) diff --git a/src/diagnostics/src/boatTest/common/CMakeLists.txt b/src/diagnostics/src/boatTest/common/CMakeLists.txt new file mode 100644 index 000000000..768c1a7e2 --- /dev/null +++ b/src/diagnostics/src/boatTest/common/CMakeLists.txt @@ -0,0 +1,2 @@ +add_library(boatTest_Common boatTest_common.cpp boatTest_common.h) +target_include_directories(boatTest_Common PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/diagnostics/src/boatTest/common/boatTest_common.cpp b/src/diagnostics/src/boatTest/common/boatTest_common.cpp new file mode 100644 index 000000000..b384696c4 --- /dev/null +++ b/src/diagnostics/src/boatTest/common/boatTest_common.cpp @@ -0,0 +1,15 @@ +#include "boatTest_common.h" + +BoatTest::BoatTest() { name = "NONESPECIFIED"; } +BoatTest::BoatTest(std::string id, testType test_type, int timeout, std::vector test_data) +{ + name = id; + type = test_type; + timeout_sec = timeout; + data = test_data; +} + +std::string BoatTest::getName(BoatTest * test) { return test->name; } +testType BoatTest::getTestType(BoatTest * test) { return test->type; } +std::vector BoatTest::getTestData(BoatTest * test) { return test->data; } +int BoatTest::getTimeout(BoatTest * test) { return test->timeout_sec; } diff --git a/src/diagnostics/src/boatTest/common/boatTest_common.h b/src/diagnostics/src/boatTest/common/boatTest_common.h new file mode 100644 index 000000000..25ad5cf38 --- /dev/null +++ b/src/diagnostics/src/boatTest/common/boatTest_common.h @@ -0,0 +1,31 @@ +#ifndef BOATTEST_COMMON_H_ +#define BOATTEST_COMMON_H_ + +/* Include Files */ +#include +#include + +/* Objects */ +typedef enum { + ROS, + CAN, + NONE, +} testType; + +class BoatTest +{ + std::string name; + testType type; + int timeout_sec; + std::vector data; + +public: + BoatTest(); + BoatTest(std::string id, testType test_type, int timeout, std::vector test_data); + std::string getName(BoatTest * test); + testType getTestType(BoatTest * test); + std::vector getTestData(BoatTest * test); + int getTimeout(BoatTest * test); +}; + +#endif diff --git a/src/diagnostics/src/boatTest/config/CMakeLists.txt b/src/diagnostics/src/boatTest/config/CMakeLists.txt new file mode 100644 index 000000000..298a8366a --- /dev/null +++ b/src/diagnostics/src/boatTest/config/CMakeLists.txt @@ -0,0 +1,3 @@ +set( + YAML_TEST_PATH_1 ${CMAKE_CURRENT_LIST_DIR}/tests.yaml CACHE INTERNAL "Path to test yaml config file" +) diff --git a/src/diagnostics/src/boatTest/config/tests.yaml b/src/diagnostics/src/boatTest/config/tests.yaml new file mode 100644 index 000000000..0fc6a77fe --- /dev/null +++ b/src/diagnostics/src/boatTest/config/tests.yaml @@ -0,0 +1,19 @@ +inputs: + - type: ROS + name: rosTestOne + timeout_sec: 5 + data: + dtype: uint64 + val: 6 + - type: ROS + name: rosTestTwo + timeout_sec: 5 + data: + dtype: uint64 + val: 7 + - type: CAN + name: canTestOne + timeout_sec: 5 + data: + dtype: float + val: 8.5 diff --git a/src/diagnostics/src/boatTest/parse_yaml/CMakeLists.txt b/src/diagnostics/src/boatTest/parse_yaml/CMakeLists.txt new file mode 100644 index 000000000..dbaebc104 --- /dev/null +++ b/src/diagnostics/src/boatTest/parse_yaml/CMakeLists.txt @@ -0,0 +1,9 @@ +add_library(parseYaml parse_yaml.cpp parse_yaml.h) +target_include_directories(parseYaml PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(parseYaml PRIVATE boatTest_Common) +target_link_libraries(parseYaml PRIVATE ryml::ryml) + +target_compile_definitions( + parseYaml PUBLIC YAML_TEST_PATH_1="${YAML_TEST_PATH_1}" +) diff --git a/src/diagnostics/src/boatTest/parse_yaml/parse_yaml.cpp b/src/diagnostics/src/boatTest/parse_yaml/parse_yaml.cpp new file mode 100644 index 000000000..9356b27e8 --- /dev/null +++ b/src/diagnostics/src/boatTest/parse_yaml/parse_yaml.cpp @@ -0,0 +1,54 @@ +#include "parse_yaml.h" + +std::string readFile(const char * file_path) +{ + std::ifstream in_file(file_path, std::ios::in | std::ios::binary); + + if (!in_file) { + std::cerr << "Could not open file: " << file_path << std::endl; + throw std::ifstream::failure("ERROR: ifstream could not open file"); + } + + std::stringstream file_buffer; + file_buffer << in_file.rdbuf(); + + return file_buffer.str(); +} + +testType getTestTypeFromStr(std::string test_type_str) +{ + if (test_type_str == "ROS") { + return ROS; + } else if (test_type_str == "CAN") { + return CAN; + } else { + return NONE; + } +} + +std::vector YamlParser::parseYaml(const char * yaml_file_path) +{ + std::vector tests; + std::string yaml_contents = readFile(yaml_file_path); + + ryml::Tree tree = ryml::parse_in_place(ryml::to_substr(yaml_contents)); + ryml::NodeRef test_array = tree["inputs"]; + + for (ryml::NodeRef const & test : test_array.children()) { + std::string test_type_str; + std::string test_name; + testType test_type; + int test_timeout; + std::vector test_data; + + test["type"] >> test_type_str; + test_type = getTestTypeFromStr(test_type_str); + test["name"] >> test_name; + test["timeout_sec"] >> test_timeout; + // TODO(unknown): add parsing for test data + + BoatTest * new_test = new BoatTest(test_name, test_type, test_timeout, test_data); + tests.push_back(new_test); + } + return tests; +} diff --git a/src/diagnostics/src/boatTest/parse_yaml/parse_yaml.h b/src/diagnostics/src/boatTest/parse_yaml/parse_yaml.h new file mode 100644 index 000000000..dbda65d94 --- /dev/null +++ b/src/diagnostics/src/boatTest/parse_yaml/parse_yaml.h @@ -0,0 +1,21 @@ +#ifndef PARSE_YAML_H_ +#define PARSE_YAML_H_ + +/* Include Files */ +#include +#include +#include +#include +#include +#include + +#include "boatTest_common.h" + +/* Yaml File Parser*/ +class YamlParser +{ +public: + std::vector parseYaml(const char * yaml_file_path); +}; + +#endif diff --git a/src/diagnostics/src/main/CMakeLists.txt b/src/diagnostics/src/main/CMakeLists.txt new file mode 100644 index 000000000..baef008fe --- /dev/null +++ b/src/diagnostics/src/main/CMakeLists.txt @@ -0,0 +1,13 @@ +add_executable(diagnostics diagnostics.cpp) + +target_link_libraries(diagnostics PRIVATE + UI_Common + parseYaml +) + +target_link_libraries(diagnostics PRIVATE boatTest_Common) +target_include_directories(diagnostics PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_compile_definitions(diagnostics PUBLIC YAML_TEST_PATH="${YAML_TEST_PATH}") diff --git a/src/diagnostics/src/main/diagnostics.cpp b/src/diagnostics/src/main/diagnostics.cpp new file mode 100644 index 000000000..80c84a3b1 --- /dev/null +++ b/src/diagnostics/src/main/diagnostics.cpp @@ -0,0 +1,43 @@ +#include "diagnostics.h" + +App::App() +{ + CommonUI * new_ui = new CommonUI(); + YamlParser * new_yaml_parser = new YamlParser(); + + ui = new_ui; + yaml_parser = new_yaml_parser; +} + +int App::appGetUserSelection(std::string * selection) { return 0; } + +App::~App() +{ + delete ui; + delete yaml_parser; +} + +int main(int argc, char ** argv) +{ + (void)argc; + (void)argv; + + int status; + App diagnostics_app; + + while (true) { + std::string user_select; + status = diagnostics_app.appGetUserSelection(&user_select); + if (status) { + std::cout << "Error in parsing user input. Exiting." << std::endl; + } + + if (user_select == "q") { + break; + } else { + std::cout << "Option " << user_select << " chosen." << std::endl; + } + } + + return 0; +} diff --git a/src/diagnostics/src/main/diagnostics.h b/src/diagnostics/src/main/diagnostics.h new file mode 100644 index 000000000..67e6436ff --- /dev/null +++ b/src/diagnostics/src/main/diagnostics.h @@ -0,0 +1,26 @@ +#ifndef DIAGNOSTICS_H_ +#define DIAGNOSTICS_H_ + +/* Include Files */ +#include +#include +#include +#include + +#include "boatTest_common.h" +#include "commonUI.h" +#include "parse_yaml.h" + +/* Classes */ +class App +{ +public: + CommonUI * ui; + YamlParser * yaml_parser; + + App(); + int appGetUserSelection(std::string * selection); + ~App(); +}; + +#endif diff --git a/src/diagnostics/src/ui/CMakeLists.txt b/src/diagnostics/src/ui/CMakeLists.txt new file mode 100644 index 000000000..e4717b2d6 --- /dev/null +++ b/src/diagnostics/src/ui/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(common) diff --git a/src/diagnostics/src/ui/common/CMakeLists.txt b/src/diagnostics/src/ui/common/CMakeLists.txt new file mode 100644 index 000000000..28aa49ccc --- /dev/null +++ b/src/diagnostics/src/ui/common/CMakeLists.txt @@ -0,0 +1,3 @@ +add_library(UI_Common commonUI.cpp commonUI.h) +target_link_libraries(UI_Common PUBLIC parseYaml boatTest_Common) +target_include_directories(UI_Common PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/diagnostics/src/ui/common/commonUI.cpp b/src/diagnostics/src/ui/common/commonUI.cpp new file mode 100644 index 000000000..c87f5521a --- /dev/null +++ b/src/diagnostics/src/ui/common/commonUI.cpp @@ -0,0 +1,28 @@ +#include "commonUI.h" + +CommonUI::CommonUI() { terminal_width = getTerminalWidth(); } +CommonUI::CommonUI(int user_set_width) { terminal_width = user_set_width; } + +int CommonUI::getTerminalWidth() { return 0; } + +void CommonUI::printDiv() const +{ + std::cout << std::endl; + + for (int i = 0; i < terminal_width; i++) { + std::cout << '='; + } + + std::cout << std::endl; +} + +void CommonUI::printCenter(std::string contents) const +{ + int left_padding = (terminal_width / 2) - (contents.size() / 2); + + for (int pad = 0; pad < left_padding; pad++) { + std::cout << ' '; + } + + std::cout << contents << std::endl; +} diff --git a/src/diagnostics/src/ui/common/commonUI.h b/src/diagnostics/src/ui/common/commonUI.h new file mode 100644 index 000000000..291a23923 --- /dev/null +++ b/src/diagnostics/src/ui/common/commonUI.h @@ -0,0 +1,36 @@ +#ifndef COMMON_UI_H_ +#define COMMON_UI_H_ + +/* Include Files */ +#include +#include + +#include +#include +#include +#include + +#include "boatTest_common.h" +#include "parse_yaml.h" + +/* Defines */ + +#define TERMINAL_WIDTH_SCALE 0.6 +#define TERMINAL_WIDTH_MIN 50 + +/* Objects */ + +class CommonUI +{ +private: + int terminal_width; + static int getTerminalWidth(); + +public: + CommonUI(); + explicit CommonUI(int user_set_width); + void printDiv() const; + void printCenter(std::string contents) const; +}; + +#endif diff --git a/src/local_pathfinding/.gitignore b/src/local_pathfinding/.gitignore new file mode 100644 index 000000000..5ad2f1cf4 --- /dev/null +++ b/src/local_pathfinding/.gitignore @@ -0,0 +1,8 @@ +# python +__pycache__/ +.mypy_cache/ +.pytest_cache/ + +# global paths with exceptions +/global_paths/*.csv +!/global_paths/mock_global_path.csv diff --git a/src/local_pathfinding/LICENSE b/src/local_pathfinding/LICENSE new file mode 100644 index 000000000..30e8e2ece --- /dev/null +++ b/src/local_pathfinding/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/local_pathfinding/README.md b/src/local_pathfinding/README.md new file mode 100644 index 000000000..b92bfcdc3 --- /dev/null +++ b/src/local_pathfinding/README.md @@ -0,0 +1,24 @@ +# Local Pathfinding + +[![Tests](https://github.com/UBCSailbot/local_pathfinding/actions/workflows/tests.yml/badge.svg)](https://github.com/UBCSailbot/local_pathfinding/actions/workflows/tests.yml) + +UBC Sailbot's local pathfinding ROS package + +## Run + +Using main launch file: `ros2 launch local_pathfinding main_launch.py` + +### Launch Parameters + +Launch arguments are added to the run command in the format `:=`. + +| name | description | value | +| ----------- | ------------- | ----------------------------------------------------- | +| `log_level` | Logging level | A [severity level][severity level] (case insensitive) | + +[severity level]: + +### Server Files + +The server files: `get_server.py` and `post_server.py` are basic http server files which are used for testing the +global_path module's GET and POST methods. diff --git a/src/local_pathfinding/global_paths/README.md b/src/local_pathfinding/global_paths/README.md new file mode 100644 index 000000000..4cd3aabd4 --- /dev/null +++ b/src/local_pathfinding/global_paths/README.md @@ -0,0 +1,41 @@ +# Global Paths Directory + +This directory contains all mock global path CSV files, +as well as a path_builder.py module. + +## path_builder.py Module + +The path_builder.py module can be used two different ways: + +GUI: The module serves a basic flask application which allows + for easy creation and modification of mock global paths. + To launch the GUI, run the following command from the root of the local_pathfinding repository: + +`python3 global_paths/path_builder/path_builder.py` + +CLI: The module can also be run from the command line for faster and more performant operations. +To use, run the following command(s) from the root of the local_pathfinding repository: + +`python3 global_paths/path_builder/path_builder.py --file_path ` (to plot) + +`python3 global_paths/path_builder/path_builder.py --file_path --interpolate 30.0` (to interpolate) + +`python3 global_paths/path_builder/path_builder.py --delete` (to delete all generated paths with timestamps) + +Using any of the command line arguments will keep the GUI from launching. + +### GUI Usage + +The Path Builder supports the following operations: + +- Path creation and editing on a 2D OpenStreetMap Map +- Path exporting +- Path importing +- Interpolation between waypoints +- Clear path function +- Purge generated path files +- 3D visualization of path + +Note: Selecting a small value for the interpolation interval spacing or having a large number of waypoints may cause +performance issues in the GUI as the javascript is not optimized. If this is an issue, +try increasing the spacing or running the interpolation from the CLI instead. diff --git a/src/local_pathfinding/global_paths/mock_global_path.csv b/src/local_pathfinding/global_paths/mock_global_path.csv new file mode 100644 index 000000000..02af68536 --- /dev/null +++ b/src/local_pathfinding/global_paths/mock_global_path.csv @@ -0,0 +1,3 @@ +latitude,longitude +48.158391,-130.253906 +48.121368,-137.022955 diff --git a/src/local_pathfinding/global_paths/path_builder/path_builder.py b/src/local_pathfinding/global_paths/path_builder/path_builder.py new file mode 100644 index 000000000..d712bee71 --- /dev/null +++ b/src/local_pathfinding/global_paths/path_builder/path_builder.py @@ -0,0 +1,334 @@ +""""Path Builder Module + +Depending on the arguments passed, this module can be used the following ways: + - To run certain path functions from the command line + - To launch and serve as the backend to the path builder GUI + + +CLI Args: + --file_path: The relative path to the global path file. + --interpolate: The interval spacing for interpolation. + --delete: Whether to delete generated global path files. +""" +import argparse +import csv +import os +import re +import webbrowser +from os import walk +from typing import Dict, List, Tuple + +import numpy as np +import plotly.graph_objects as go +from custom_interfaces.msg import HelperLatLon, Path +from flask import Flask, jsonify, render_template, request + +from local_pathfinding.global_path import ( + _interpolate_path, + calculate_interval_spacing, + write_to_file, +) + +app = Flask(__name__) + +DEFAULT_DIR = "/workspaces/sailbot_workspace/src/local_pathfinding/global_paths/" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--file_path", help="The relative path to the global path file.") + parser.add_argument( + "--interpolate", help="The interval spacing for interpolation.", type=float + ) + parser.add_argument( + "--delete", help="Whether to delete generated global path files.", action="store_true" + ) + args = parser.parse_args() + + if args.delete: + delete_files() + return + + # Manually plot or interpolate path from file if a filepath is given + if args.file_path is not None: + lats, lons = get_lats_and_lons(file_path=args.file_path) + + if args.interpolate is not None: + # interpolate and write interpolated path to new file + # waypoints will be a complete path including start position in this context + waypoints = [ + (HelperLatLon(latitude=lats[i], longitude=lons[i])) for i in range(len(lats)) + ] + # get the start position from the first waypoint + pos = waypoints[0] + # Calculate actual interval spacing in the current path + path_spacing = calculate_interval_spacing(pos=pos, waypoints=waypoints) + + path = Path(waypoints=waypoints) + path = _interpolate_path( + global_path=path, + interval_spacing=args.interpolate, + pos=pos, + path_spacing=path_spacing, + write=True, + file_path=args.file_path, + ) + + # obtain the newly interpolated waypoints for printing + lats, lons = zip(*[(item.latitude, item.longitude) for item in path.waypoints]) + else: + plot_global_path(lats, lons) + + print("global path:", lats_and_lons_to_dict(lats, lons), sep="\n") + + # Open the GUI if no filepath is specified + else: + # extra_dirs is used to reload the server when a file is changed for debugging + extra_dirs = [ + "/workspaces/sailbot_workspace/src/local_pathfinding/global_paths/path_builder", + ] + extra_files = extra_dirs[:] + for extra_dir in extra_dirs: + for dirname, dirs, files in walk(extra_dir): + for filename in files: + filename = os.path.join(dirname, filename) + if os.path.isfile(filename): + extra_files.append(filename) + + webbrowser.open("http://127.0.0.1:5000") + # opens two tabs on startup when debug=True + app.run(host="0.0.0.0", port=5000, debug=False, extra_files=extra_files) + + +@app.route("/") +def _index(): + return render_template("index.html") + + +@app.route("/export_waypoints", methods=["POST"]) +def _export_waypoints(): + data = request.json + result = _handle_export(data) + return jsonify(result) + + +@app.route("/import_waypoints", methods=["POST"]) +def _import_waypoints(): + data = request.json + result = _handle_import(data) + return jsonify(result) + + +@app.route("/plot_path", methods=["POST"]) +def _plot_path(): + data = request.json + result = _handle_plot(data) + return jsonify(result) + + +@app.route("/delete_paths", methods=["POST"]) +def _delete_paths(): + data = request.json + result = _handle_delete(data) + return jsonify(result) + + +@app.route("/interpolate_path", methods=["POST"]) +def _interpolate_path_(): + data = request.json + result = _handle_interpolate(data) + return jsonify(result) + + +def _handle_export(data): + filename = data.get("filename", "") + waypoints = data.get("waypoints", []) + + file_path = os.path.join(DEFAULT_DIR, filename) + + if not str(filename).endswith(".csv"): + file_path = file_path + ".csv" + + # convert from json to list of HelperLatLon + waypoints = [ + (HelperLatLon(latitude=float(item["lat"]), longitude=float(item["lon"]))) + for item in waypoints + ] + + # convert to Path, to pass to file writer + path = Path(waypoints=waypoints) + + try: + write_to_file(file_path=file_path, global_path=path, tmstmp=False) + return {"status": "success", "message": "Waypoints exported successfully"} + except Exception as e: + return {"status": "error", "message": f"Error exporting waypoints: {str(e)}"} + + +def _handle_import(data): + filename = data.get("filename", "") + file_path = os.path.join(DEFAULT_DIR, filename) + + if not str(filename).endswith(".csv"): + file_path = file_path + ".csv" + + try: + with open(file_path, "r") as f: + reader = csv.reader(f) + # skip header + reader.__next__() + waypoints = list(reader) + waypoints = [{"lat": float(item[0]), "lon": float(item[1])} for item in waypoints] + return { + "status": "success", + "message": "Waypoints imported successfully", + "waypoints": waypoints, + } + except Exception as e: + return {"status": "error", "message": f"Error importing waypoints: {str(e)}"} + + +def _handle_plot(data): + waypoints = data.get("waypoints", []) + + # convert from json to list of HelperLatLon + waypoints = [(float(item["lat"]), float(item["lon"])) for item in waypoints] + waypoints = np.array(waypoints) + lats, lons = waypoints.T + try: + plot_global_path(lats, lons) + return {"status": "success", "message": "Path plotted successfully"} + except Exception as e: + return {"status": "error", "message": f"Error plotting path: {str(e)}"} + + +def _handle_delete(data): + key = data.get("key", None) + try: + delete_files(key=key) + return {"status": "success", "message": "Paths deleted successfully"} + except Exception as e: + return {"status": "error", "message": f"Error deleting paths: {str(e)}"} + + +def _handle_interpolate(data): + waypoints = data.get("waypoints", []) + interval_spacing = float(data.get("interval_spacing", 30.0)) + + # convert from json to list of HelperLatLon + waypoints = [ + (HelperLatLon(latitude=float(item["lat"]), longitude=float(item["lon"]))) + for item in waypoints + ] + + # convert to Path, to pass to interpolator + path = Path(waypoints=waypoints) + + # pop out the first waypoint as point1, since the interpolator will add it to the start of path + point1 = path.waypoints.pop(0) + + try: + path_spacing = calculate_interval_spacing(pos=point1, waypoints=path.waypoints) + path = _interpolate_path( + global_path=path, + interval_spacing=interval_spacing, + pos=point1, + path_spacing=path_spacing, + ) + # add first waypoint back to the start of path + path.waypoints = [point1] + path.waypoints + # convert waypoints to serializable format + waypoints = [ + {"lat": float(item.latitude), "lon": float(item.longitude)} for item in path.waypoints + ] + return { + "status": "success", + "message": "Waypoints exported successfully", + "waypoints": waypoints, + } + except Exception as e: + return {"status": "error", "message": f"Error exporting waypoints: {str(e)}"} + + +def delete_files(key=None): + """ "Deletes all files in /global_paths that match the given key (if any) or that contain a + timestamp.""" + + dir_path = "/workspaces/sailbot_workspace/src/local_pathfinding/global_paths" + files = os.listdir(dir_path) + + timestamp_pattern = re.compile(r"_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.csv") + + # check if a keyword was entered + if key is not None: + key = re.compile(rf"{key}") + + for file_name in files: + if re.search(timestamp_pattern, file_name) or ( + key is not None and re.search(key, file_name) + ): + file_path = os.path.join(dir_path, file_name) + try: + os.remove(file_path) + print(f"Deleted: {os.path.basename(file_path)} from /global_paths") + except OSError as e: + print(f"Error deleting {file_path}: {e}") + + +def get_lats_and_lons(file_path: str) -> Tuple[List[float], List[float]]: + """Reads a csv file and returns the lats and lons as lists""" + lats = [] + lons = [] + + with open(file_path, "r") as file: + reader = csv.reader(file) + # skip header + reader.__next__() + for row in reader: + lats.append(float(row[0])) + lons.append(float(row[1])) + + return lats, lons + + +def plot_global_path(lats, lons): + """3D plotter with plotly""" + fig = go.Figure( + data=go.Scattergeo( + lat=lats, + lon=lons, + mode="markers+lines", + line=dict(width=2, color="blue"), + ) + ) + + fig.update_layout( + title_text="Mock Global Path Plot", + showlegend=True, + geo=dict( + showland=True, + showcountries=True, + showocean=True, + countrywidth=0.5, + landcolor="rgb(230, 145, 56)", + lakecolor="rgb(0, 255, 255)", + oceancolor="rgb(0, 255, 255)", + projection=dict(type="orthographic", rotation=dict(lon=-100, lat=40, roll=0)), + lonaxis=dict(showgrid=True, gridcolor="rgb(102, 102, 102)", gridwidth=0.5), + lataxis=dict(showgrid=True, gridcolor="rgb(102, 102, 102)", gridwidth=0.5), + ), + ) + + fig.show() + + +def lats_and_lons_to_dict( + lats: List[float], lons: List[float], num_decimals: int = 4 +) -> Dict[int, str]: + return { + i: f"({lats[i]:.{num_decimals}f}, {lons[i]:.{num_decimals}f})" for i in range(len(lats)) + } + + +if __name__ == "__main__": + main() diff --git a/src/local_pathfinding/global_paths/path_builder/static/css/style.css b/src/local_pathfinding/global_paths/path_builder/static/css/style.css new file mode 100644 index 000000000..caa2ca636 --- /dev/null +++ b/src/local_pathfinding/global_paths/path_builder/static/css/style.css @@ -0,0 +1,98 @@ +#map { + position: relative; + cursor:pointer; +} +#map:active { + cursor:grabbing; +} +#main_view { + height: 500px; + display:flex; + margin-top: 20px; +} +#banner { + background-color: #1665a2; + overflow: hidden; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0px 0px 8px 0px; +} +#banner_btns { + margin: 10px; + display: flex; + flex-direction: column; +} + +button:active { + background-color: #006AE8; +} + +button:hover { + background-color: #1C84FF; +} + +.text { + font-family: 'Roboto', sans-serif; + overflow: hidden; +} +.container { + margin: 0 auto; + padding: 0 10px; + width: 100%; + overflow: hidden; +} +.waypoints { + overflow-y:scroll; + background-color: #ebf7ff; +} +h1, h2 { + color: #ffffff; +} +.buttons { + margin: 10px; + padding: 10px; + box-shadow:0px 0px 0px 0px; + display:flex; +} +button { + align-items: center; + appearance: button; + background-color: #0d3d61; + border-radius: 8px; + border-style: none; + box-shadow: rgba(255, 255, 255, 0.26) 0 1px 2px inset; + box-sizing: border-box; + color: #fff; + cursor: pointer; + display: flex; + flex-direction: row; + flex-shrink: 0; + font-family: "Roboto",sans-serif; + font-size: 100%; + line-height: 1.15; + margin: 5px; + padding: 10px 21px; + text-align: center; + text-transform: none; + transition: color .13s ease-in-out,background .13s ease-in-out,opacity .13s ease-in-out,box-shadow .13s ease-in-out; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; +} +.btn { + margin:5px; +} +table { + border-collapse: collapse; + width: 100%; +} +th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} +th { + background-color: #f2f2f2; +} diff --git a/src/local_pathfinding/global_paths/path_builder/static/js/script.js b/src/local_pathfinding/global_paths/path_builder/static/js/script.js new file mode 100644 index 000000000..baf1620ee --- /dev/null +++ b/src/local_pathfinding/global_paths/path_builder/static/js/script.js @@ -0,0 +1,279 @@ +var MAP_ZOOM = 6; +// Initialize the map +var map = L.map('map').setView([49.1536, -125.9067], MAP_ZOOM); + +// Load a tile layer +L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' +}).addTo(map); + +var waypoints = []; + +// Event listener for clicking on the map +map.on('click', function(e) { + // Get coordinates of the clicked point + var lat = e.latlng.lat.toFixed(MAP_ZOOM); + var lon = e.latlng.lng.toFixed(MAP_ZOOM); + + // Add coordinates to the waypoints array + waypoints.push({lat,lon}); + refresh(); +}); + +function draw_marker(item){ + L.marker([item.lat, item.lon]).addTo(map).bindPopup(`Waypoint: ${item.lat}, ${item.lon}`); +} + +function draw_polyline(item, index){ + if (index > 0){ + var prevLatLon = waypoints[index - 1]; + L.polyline([[prevLatLon.lat, prevLatLon.lon], [item.lat, item.lon]]).addTo(map); + } +} + +function refresh(){ + + // clear map and redraw markers and polylines + map.eachLayer(function(layer) { + if (layer instanceof L.Marker || layer instanceof L.Polyline) { + layer.remove(); + } + }); + + waypoints.forEach(draw_marker); + + if (waypoints.length > 1) { + waypoints.forEach(draw_polyline); + } + + update_waypoints_table(); +} +// Button event handlers +function clear_path(){ + + var confirmation = confirm("Are you sure you want to clear all waypoints? You cannot undo this action."); + + if (confirmation) { + waypoints = []; + refresh(); + } +} +function import_file(){ + + confirmation = true; + + if (waypoints.length > 0) { + + var confirmation = confirm("Be sure you have exported your current path before importing a new one. Are you sure you want to continue?"); + } + + if (confirmation) { + waypoints = []; + refresh(); + + var filename = window.prompt("Enter the filename of the CSV file to import:", ""); + + if (filename === null) { + // User clicked Cancel, do nothing + return; + } + + else if (filename === "") { + alert("You must enter a filename."); + return; + } + + fetch('/import_waypoints', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filename: filename }), + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success'){ + waypoints = data.waypoints; + refresh(); + } else { + alert('Error importing waypoints. Please check the server logs for details.'); + } + }) + } + + +} +function interpolate(){ + var confirmation = confirm("Are you sure you want to interpolate the path? You cannot undo this action."); + + if (confirmation) { + var interval_spacing = window.prompt("Enter the desired interval spacing in km:", "30"); + fetch('/interpolate_path', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ waypoints: waypoints, interval_spacing: interval_spacing }), + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success'){ + waypoints = data.waypoints; + refresh(); + } else { + alert('Error interpolating waypoints. Please check the server logs for details.'); + } + }) + .catch(error => { + console.error('Error:', error); + }); + } +} +function delete_paths(){ + + var key = window.prompt("Enter the keyword in the filenames you want to delete:", "test"); + + if (key === null) { + // User clicked Cancel, do nothing + return; + } + + var confirmation = confirm(`Are you sure you want to delete all paths containing the keyword \"${key}\"? + All timestamped paths will also be deleted.`); + + if (confirmation) { + + fetch('/delete_paths', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key: key }), + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'error'){ + alert('Error deleting paths. Please check the server logs for details.'); + } + else{ + alert(`\"${key}\" Paths deleted successfully.`); + } + }) + .catch(error => { + console.error('Error:', error); + }); + } +} + +function plot(){ + if (waypoints.length < 2) { + alert('Please add at least two waypoints to plot a path.'); + return; + } + else{ + fetch('/plot_path', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ waypoints: waypoints }), + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'error'){ + alert('Error plotting waypoints. Please check the server logs for details.'); + } + }) + .catch(error => { + console.error('Error:', error); + }); + } +} + + +function prompt_and_export() { + if (waypoints.length < 2) { + alert('Please add at least two waypoints to export.'); + return; + } + // Prompt user for filename + var filename = window.prompt("Enter the desired filename for the CSV file:", ""); + + if (filename === null) { + // User clicked Cancel, do nothing + return; + } + + if (filename === "") { + alert("You must enter a filename."); + return; + } + + fetch('/export_waypoints', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filename: filename, waypoints: waypoints }), + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success'){ + alert('Waypoints exported successfully.'); + } else { + alert('Error exporting waypoints. Please check the server logs for details.'); + } + }) + .catch(error => { + console.error('Error:', error); + }); +} + +function delete_waypoint(index) { + // Remove the waypoint from the waypoints array + waypoints.splice(index, 1); + + refresh(); +} + +function edit_waypoint(index) { + var lat = window.prompt("Enter the new latitude:", waypoints[index].lat); + var lon = window.prompt("Enter the new longitude:", waypoints[index].lon); + + if (lat === null || lon === null) { + // User clicked Cancel, do nothing + return; + } + + if (lat === "" || lon === "") { + alert("You must enter a latitude and longitude."); + return; + } + + waypoints[index].lat = lat; + waypoints[index].lon = lon; + + refresh(); +} + +function update_waypoints_table() { + var tableBody = document.getElementById("waypointsTable").getElementsByTagName('tbody')[0]; + + // Clear existing rows + tableBody.innerHTML = ''; + + // Add waypoints to the table + waypoints.forEach(function (waypoint, index) { + var newRow = tableBody.insertRow(tableBody.rows.length); + + var cell1 = newRow.insertCell(0); + var cell2 = newRow.insertCell(1); + var cell3 = newRow.insertCell(2); + + cell1.innerHTML = waypoint.lat; + cell2.innerHTML = waypoint.lon; + cell3.innerHTML = ``; + cell3.innerHTML += ``; + cell3.style.display = "flex"; + }); +} diff --git a/src/local_pathfinding/global_paths/path_builder/templates/index.html b/src/local_pathfinding/global_paths/path_builder/templates/index.html new file mode 100644 index 000000000..160795c6f --- /dev/null +++ b/src/local_pathfinding/global_paths/path_builder/templates/index.html @@ -0,0 +1,55 @@ + + + + + + Global Path Builder + + + + + + + +
+ + +
+ +
+ +
+ +

Waypoints

+ + + + + + + + + + + + +
LatitudeLongitudeActions
+
+
+
+ + + diff --git a/src/local_pathfinding/launch/main_launch.py b/src/local_pathfinding/launch/main_launch.py new file mode 100644 index 000000000..fd83a315d --- /dev/null +++ b/src/local_pathfinding/launch/main_launch.py @@ -0,0 +1,131 @@ +"""Launch file that runs all nodes for the local pathfinding ROS package.""" + +import importlib +import os +from typing import List, Tuple + +from launch_ros.actions import Node + +from launch.actions import DeclareLaunchArgument, OpaqueFunction +from launch.launch_context import LaunchContext +from launch.launch_description import LaunchDescription +from launch.some_substitutions_type import SomeSubstitutionsType +from launch.substitutions import LaunchConfiguration + +# Local launch arguments and constants +PACKAGE_NAME = "local_pathfinding" + +# Add args with DeclareLaunchArguments object(s) and utilize in setup_launch() +LOCAL_LAUNCH_ARGUMENTS: List[DeclareLaunchArgument] = [] + + +def generate_launch_description() -> LaunchDescription: + """The launch file entry point. Generates the launch description for the `local_pathfinding` + package. + + Returns: + LaunchDescription: The launch description. + """ + global_launch_arguments, global_environment_vars = get_global_launch_arguments() + return LaunchDescription( + [ + *global_launch_arguments, + *global_environment_vars, + *LOCAL_LAUNCH_ARGUMENTS, + OpaqueFunction(function=setup_launch), + ] + ) + + +def get_global_launch_arguments() -> Tuple: + """Gets the global launch arguments and environment variables from the global launch file. + + Returns: + Tuple: The global launch arguments and environment variables. + """ + ros_workspace = os.getenv("ROS_WORKSPACE", default="/workspaces/sailbot_workspace") + global_main_launch = os.path.join(ros_workspace, "src", "global_launch", "main_launch.py") + spec = importlib.util.spec_from_file_location("global_launch", global_main_launch) + if spec is None: + raise ImportError(f"Couldn't import global_launch module from {global_main_launch}") + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] # spec is not None + spec.loader.exec_module(module) # type: ignore[union-attr] # spec is not None + global_launch_arguments = module.GLOBAL_LAUNCH_ARGUMENTS + global_environment_vars = module.ENVIRONMENT_VARIABLES + return global_launch_arguments, global_environment_vars + + +def setup_launch(context: LaunchContext) -> List[Node]: + """Collects launch descriptions that describe the system behavior in the `local_pathfinding` + package. + + Args: + context (LaunchContext): The current launch context. + + Returns: + List[Node]: Nodes to launch. + """ + mode = LaunchConfiguration("mode").perform(context) + launch_description_entities = [] + launch_description_entities.append(get_navigate_node_description(context)) + if mode == "development": + launch_description_entities.append(get_mock_global_path_node_description(context)) + return launch_description_entities + + +def get_navigate_node_description(context: LaunchContext) -> Node: + """Gets the launch description for the navigate_main node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the navigate_main node. + """ + node_name = "navigate_main" + ros_parameters = [LaunchConfiguration("config").perform(context)] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + + node = Node( + package=PACKAGE_NAME, + executable="navigate", + name=node_name, + output="screen", + emulate_tty=True, + parameters=ros_parameters, + ros_arguments=ros_arguments, + ) + + return node + + +def get_mock_global_path_node_description(context: LaunchContext) -> Node: + """Gets the launch description for the mgp_main node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the mgp_main node. + """ + node_name = "mgp_main" + ros_parameters = [LaunchConfiguration("config").perform(context)] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + + node = Node( + package=PACKAGE_NAME, + executable="mock_global_path", + name=node_name, + output="screen", + emulate_tty=True, + parameters=ros_parameters, + ros_arguments=ros_arguments, + ) + + return node diff --git a/src/local_pathfinding/local_pathfinding/__init__.py b/src/local_pathfinding/local_pathfinding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/local_pathfinding/local_pathfinding/coord_systems.py b/src/local_pathfinding/local_pathfinding/coord_systems.py new file mode 100644 index 000000000..824f66a24 --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/coord_systems.py @@ -0,0 +1,82 @@ +"""Functions and classes for converting between coordinate systems.""" + +import math +from typing import NamedTuple + +from custom_interfaces.msg import HelperLatLon +from pyproj import Geod + +GEODESIC = Geod(ellps="WGS84") + + +class XY(NamedTuple): + """2D Cartesian coordinate representation. + + Attributes: + x (float): X coordinate. + y (float): Y coordinate. + """ + + x: float + y: float + + +def cartesian_to_true_bearing(cartesian: float) -> float: + """Convert a cartesian angle to the equivalent true bearing. + + Args: + cartesian (float): Angle where 0 is east and values increase counter-clockwise. + + Returns: + float: Angle where 0 is north and values increase clockwise. + """ + return (90 - cartesian + 360) % 360 + + +def meters_to_km(meters: float) -> float: + return meters / 1000 + + +def km_to_meters(km: float) -> float: + return km * 1000 + + +def latlon_to_xy(reference: HelperLatLon, latlon: HelperLatLon) -> XY: + """Convert a geographical coordinate to a 2D Cartesian coordinate given a reference point. + + Args: + reference (HelperLatLon): Origin of the Cartesian coordinate system. + latlon (HelperLatLon): Coordinate to be converted to the Cartesian coordinate system. + + Returns: + XY: The x and y components in km. + """ + forward_azimuth_deg, _, distance_m = GEODESIC.inv( + reference.longitude, reference.latitude, latlon.longitude, latlon.latitude + ) + true_bearing = math.radians(forward_azimuth_deg) + distance = meters_to_km(distance_m) + + return XY( + x=distance * math.sin(true_bearing), + y=distance * math.cos(true_bearing), + ) + + +def xy_to_latlon(reference: HelperLatLon, xy: XY) -> HelperLatLon: + """Convert a 2D Cartesian coordinate to a geographical coordinate given a reference point. + + Args: + reference (HelperLatLon): Coordinate that is the origin of the Cartesian coordinate system. + xy (XY): Coordinate to be converted to the geographical coordinate system. + + Returns: + HelperLatLon: The latitude and longitude in degrees. + """ + true_bearing = math.degrees(math.atan2(xy.x, xy.y)) + distance = km_to_meters(math.hypot(*xy)) + dest_lon, dest_lat, _ = GEODESIC.fwd( + reference.longitude, reference.latitude, true_bearing, distance + ) + + return HelperLatLon(latitude=dest_lat, longitude=dest_lon) diff --git a/src/local_pathfinding/local_pathfinding/global_path.py b/src/local_pathfinding/local_pathfinding/global_path.py new file mode 100644 index 000000000..1128a76c8 --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/global_path.py @@ -0,0 +1,440 @@ +"""The Global Path Module, which retrieves the global path from a specified http source and +sends it to NET via POST request. + +The main function accepts two CLI arguments: + file_path (str): The path to the global path csv file. + --interval (float, Optional): The desired path interval length in km. +""" + +import argparse +import csv +import json +import os +import time +from datetime import datetime +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +import numpy as np +from custom_interfaces.msg import HelperLatLon, Path + +from local_pathfinding.coord_systems import GEODESIC, meters_to_km + +GPS_URL = "http://localhost:3005/api/gps" +PATH_URL = "http://localhost:8081/global-path" +GLOBAL_PATHS_FILE_PATH = "/workspaces/sailbot_workspace/src/local_pathfinding/global_paths" +PERIOD = 5 # seconds + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("file_path", help="The path to the global path csv file.") + parser.add_argument("--interval", help="Desired path interval length.", type=float) + args = parser.parse_args() + + file_path = args.file_path + path_mod_tmstmp = None + pos = None + + try: + path = get_path(file_path) + print(f"retrieved path from {file_path}", path_to_dict(path)) + except FileNotFoundError: + print(f"{file_path} not found. Please enter a valid file path.") + exit(1) + + # Main service loop + while True: + time.sleep(PERIOD) + timestamp = time.ctime(os.path.getmtime(file_path)) + + # We should try to retrieve the position on every loop + pos = get_pos() + + if pos is None: + print(f"Failed to retrieve position from {GPS_URL}") + continue + + position_delta = meters_to_km( + GEODESIC.inv( + lats1=pos.latitude, + lons1=pos.longitude, + lats2=path.waypoints[0].latitude, + lons2=path.waypoints[0].longitude, + )[2] + ) + + # exit loop if the path has not been modified or interval lengths are fine + if (timestamp == path_mod_tmstmp) and ( + (args.interval is None) or position_delta <= args.interval + ): + continue + + if args.interval is not None: + # interpolate path will interpolate new path and save it to a new csv file + path = interpolate_path( + path=path, + pos=pos, + interval_spacing=args.interval, + file_path=file_path, + ) + + if post_path(path): + print("Global path successfully updated.") + print(f"position was {pos}") + file_path = get_most_recent_file(GLOBAL_PATHS_FILE_PATH) + timestamp = time.ctime(os.path.getmtime(file_path)) + else: + # if the post was unsuccessful, we should try again + # so don't update the timestamp + continue + + path_mod_tmstmp = timestamp + + +def get_most_recent_file(directory_path: str) -> str: + """ + Returns the most recently modified file in the specified directory. + + Args: + directory_path (str): The path to the directory containing the files. + + Returns: + str: The path to the most recently modified file. + """ + all_files = os.listdir(directory_path) + + # Filter out directories and get the full file paths + files = [ + os.path.join(directory_path, file) + for file in all_files + if os.path.isfile(os.path.join(directory_path, file)) + ] + + # Sort the files based on their last modification time + files.sort(key=lambda x: os.path.getmtime(x), reverse=True) + + if files: + return files[0] + else: + return "" + + +def get_path(file_path: str) -> Path: + """Returns the global path from the specified file path. + + Args: + file_path (str): The path to the global path csv file. + + Returns: + (Path): The global path retrieved from the csv file. + """ + path = Path() + + with open(file_path, "r") as file: + reader = csv.reader(file) + # skip header + reader.__next__() + for row in reader: + path.waypoints.append(HelperLatLon(latitude=float(row[0]), longitude=float(row[1]))) + return path + + +def post_path(path: Path) -> bool: + """Sends the global path to NET via POST request. + + Args: + path (Path): The global path. + + Returns: + bool: Whether or not the global path was successfully posted. + """ + waypoints = [ + {"latitude": float(item.latitude), "longitude": float(item.longitude)} + for item in path.waypoints + ] + + # the timestamp format will be -- :: + timestamp = datetime.now().strftime("%y-%m-%d %H:%M:%S") + + data = {"waypoints": waypoints, "timestamp": timestamp} + + json_data = json.dumps(data).encode("utf-8") + try: + urlopen(PATH_URL, json_data) + return True + except HTTPError as http_error: + print(f"HTTP Error: {http_error.code}") + except URLError as url_error: + print(f"URL Error: {url_error.reason}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + + return False + + +def get_pos() -> HelperLatLon: + """Returns the current position of sailbot, retrieved from the an http GET request. + + Returns: + HelperLatLon: The current position of sailbot + OR + None: If the position could not be retrieved. + """ + try: + position = json.loads(urlopen(GPS_URL).read()) + except HTTPError as http_error: + print(f"HTTP Error: {http_error.code}") + return None + except URLError as url_error: + print(f"URL Error: {url_error.reason}") + return None + except ConnectionResetError as connect_error: + print(f"Connection Reset Error: {connect_error}") + return None + except Exception as e: + print(f"An unexpected error occurred: {e}") + return None + + if len(position["data"]) == 0: + print(f"Connection to {GPS_URL} successful. No position data available.") + return None + + latitude = position["data"][-1]["latitude"] + longitude = position["data"][-1]["longitude"] + pos = HelperLatLon(latitude=latitude, longitude=longitude) + + return pos + + +def generate_path( + dest: HelperLatLon, + interval_spacing: float, + pos: HelperLatLon, + write: bool = False, + file_path: str = "", +) -> Path: + """Returns a path from the current GPS location to the destination point. + Waypoints are evenly spaced along the path according to the interval_spacing parameter. + Path does not include pos, but does include dest as the final element. + + If write is True, the path is written to a new csv file in the same directory as file_path, + with the name of the original file, appended with a timestamp. + + Args: + dest (HelperLatLon): The destination point + interval_spacing (float): The desired distance between waypoints on the path + pos (HelperLatLon): The current GPS location + write (bool, optional): Whether to write the path to a new csv file, default False + file_path (str, optional): The filepath to the global path csv file, default empty + + Returns: + Path: The generated path + """ + global_path = Path() + + lat1 = pos.latitude + lon1 = pos.longitude + + lat2 = dest.latitude + lon2 = dest.longitude + + distance = meters_to_km(GEODESIC.inv(lats1=lat1, lons1=lon1, lats2=lat2, lons2=lon2)[2]) + + # minimum number of waypoints to not exceed interval_spacing + n = np.floor(distance / interval_spacing) + n = max(1, n) + + # npts returns a path with neither pos nor dest included + global_path_tuples = GEODESIC.npts(lon1=lon1, lat1=lat1, lon2=lon2, lat2=lat2, npts=n) + + # npts returns (lon,lat) tuples, its backwards for some reason + for lon, lat in global_path_tuples: + global_path.waypoints.append(HelperLatLon(latitude=lat, longitude=lon)) + + # append the destination point + global_path.waypoints.append(HelperLatLon(latitude=lat2, longitude=lon2)) + + if write: + write_to_file(file_path=file_path, global_path=global_path) + + return global_path + + +def _interpolate_path( + global_path: Path, + interval_spacing: float, + pos: HelperLatLon, + path_spacing: list[float], + write: bool = False, + file_path: str = "", +) -> Path: + """Interpolates and inserts subpaths between any waypoints which are spaced too far apart. + + Args: + global_path (Path): The path to interpolate between + interval_spacing (float): The desired spacing between waypoints + pos (HelperLatLon): The current GPS location + path_spacing (list[float]): The distances between pairs of points in global_path + write (bool, optional): Whether to write the path to a new csv file, default False + file_path (str, optional): The filepath to the global path csv file, default empty + + Returns: + Path: The interpolated path + """ + + waypoints = [pos] + global_path.waypoints + + i, j = 0, 0 + while i < len(path_spacing): + if path_spacing[i] > interval_spacing: + # interpolate a new sub path between the two waypoints + pos = waypoints[j] + dest = waypoints[j + 1] + + sub_path = generate_path( + dest=dest, + interval_spacing=interval_spacing, + pos=pos, + ) + # insert sub path into path + waypoints[j + 1 : j + 1] = sub_path.waypoints[:-1] + # shift indices to account for path insertion + j += len(sub_path.waypoints) - 1 + + i += 1 + j += 1 + # remove pos from waypoints again + waypoints.pop(0) + + global_path.waypoints = waypoints + + if write: + write_to_file(file_path=file_path, global_path=global_path) + + return global_path + + +def interpolate_path( + path: Path, + pos: HelperLatLon, + interval_spacing: float, + file_path: str, + write=True, +) -> Path: + """Interpolates path to ensure the interval lengths are less than or equal to the specified + interval spacing. + + Args: + path (Path): The global path. + pos (HelperLatLon): The current position of the vehicle. + interval_spacing (float): The desired interval spacing. + file_path (str): The path to the global path csv file. + write (bool, optional): Whether or not to write the new path to a csv file. Default True. + + Returns: + Path: The interpolated path. + """ + + # obtain the actual distances between every waypoint in the path + path_spacing = calculate_interval_spacing(pos, path.waypoints) + + # check if global path is just a destination point + if len(path.waypoints) < 2: + path = generate_path( + dest=path.waypoints[0], + interval_spacing=interval_spacing, + pos=pos, + write=write, + file_path=file_path, + ) + # Check if any waypoints are too far apart + elif max(path_spacing) > interval_spacing: + path = _interpolate_path( + global_path=path, + interval_spacing=interval_spacing, + pos=pos, + path_spacing=path_spacing, + write=write, + file_path=file_path, + ) + + return path + + +def calculate_interval_spacing(pos: HelperLatLon, waypoints: list[HelperLatLon]) -> list[float]: + """Returns the distances between pairs of points in a list of latitudes and longitudes, + including pos as the first point. + + Args: + pos (HelperLatLon): The gps position of the boat + waypoints (list[HelperLatLon]): The list of waypoints + + Returns: + list[float]: The distances between pairs of points in waypoints [km] + """ + all_coords = [(pos.latitude, pos.longitude)] + [ + (waypoint.latitude, waypoint.longitude) for waypoint in waypoints + ] + + coords_array = np.array(all_coords) + + lats1, lons1 = coords_array[:-1].T + lats2, lons2 = coords_array[1:].T + + distances = GEODESIC.inv(lats1=lats1, lons1=lons1, lats2=lats2, lons2=lons2)[2] + + distances = [meters_to_km(distance) for distance in distances] + + return distances + + +def write_to_file(file_path: str, global_path: Path, tmstmp: bool = True) -> Path: + """Writes the global path to a new, timestamped csv file. + + Args + file_path (str): The filepath to the global path csv file + global_path (Path): The global path to write to file + tmstmp (bool, optional): Whether to append a timestamp to the file name, default True + + Raises: + ValueError: If file_path is not to an existing `global_paths` directory + """ + + # check if file_path is a valid file path + if not os.path.isdir(os.path.dirname(file_path)) or not str( + os.path.dirname(file_path) + ).endswith("global_paths"): + raise ValueError(f"Invalid file path: {file_path}") + + if tmstmp: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + dst_file_path = file_path.removesuffix(".csv") + f"_{timestamp}.csv" + else: + dst_file_path = file_path + + with open(dst_file_path, "w") as file: + writer = csv.writer(file) + writer.writerow(["latitude", "longitude"]) + for waypoint in global_path.waypoints: + writer.writerow([waypoint.latitude, waypoint.longitude]) + + +def path_to_dict(path: Path, num_decimals: int = 4) -> dict[int, str]: + """Converts a Path msg to a dictionary suitable for printing. + + Args: + path (Path): The Path msg to be converted. + num_decimals (int, optional): The number of decimal places to round to, default 4. + + Returns: + dict[int, str]: Keys are the indices of the formatted latlon waypoints. + """ + return { + i: f"({waypoint.latitude:.{num_decimals}f}, {waypoint.longitude:.{num_decimals}f})" + for i, waypoint in enumerate(path.waypoints) + } + + +if __name__ == "__main__": + main() diff --git a/src/local_pathfinding/local_pathfinding/local_path.py b/src/local_pathfinding/local_pathfinding/local_path.py new file mode 100644 index 000000000..75551d21c --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/local_path.py @@ -0,0 +1,102 @@ +"""The path to the next global waypoint, represented by the `LocalPath` class.""" + +from typing import List, Optional, Tuple + +from rclpy.impl.rcutils_logger import RcutilsLogger + +from custom_interfaces.msg import GPS, AISShips, HelperLatLon, Path, WindSensor +from local_pathfinding.ompl_path import OMPLPath + + +class LocalPathState: + """Gathers and stores the state of Sailbot. + The attributes' units and conventions can be found in the ROS msgs they are derived from in the + custom_interfaces repository. + + Attributes: + `position` (Tuple[float, float]): Latitude and longitude of Sailbot. + `speed` (float): Speed of Sailbot. + `heading` (float): Direction that Sailbot is pointing. + `ais_ships` (List[HelperAISShip]): Information about nearby ships. + `global_path` (List[Tuple[float, float]]): Path to the destination that Sailbot is + navigating along. + `wind_speed` (float): Wind speed. + `wind_direction` (int): Wind direction. + `planner` (str): Planner to use for the OMPL query. + """ + + def __init__( + self, + gps: GPS, + ais_ships: AISShips, + global_path: Path, + filtered_wind_sensor: WindSensor, + planner: str, + ): + """Initializes the state from ROS msgs.""" + if gps: # TODO: remove when mock can be run + self.position = (gps.lat_lon.latitude, gps.lat_lon.longitude) + self.speed = gps.speed.speed + self.heading = gps.heading.heading + + if ais_ships: # TODO: remove when mock can be run + self.ais_ships = [ship for ship in ais_ships.ships] + + if global_path: # TODO: remove when mock can be run + self.global_path = [ + HelperLatLon(latitude=waypoint.latitude, longitude=waypoint.longitude) + for waypoint in global_path.waypoints + ] + + if filtered_wind_sensor: # TODO: remove when mock can be run + self.wind_speed = filtered_wind_sensor.speed.speed + self.wind_direction = filtered_wind_sensor.direction + + self.planner = planner + + +class LocalPath: + """Sets and updates the OMPL path and the local waypoints + + Attributes: + `_logger` (RcutilsLogger): ROS logger. + `_ompl_path` (Optional[OMPLPath]): Raw representation of the path from OMPL. + `waypoints` (Optional[List[Tuple[float, float]]]): List of coordinates that form the path + to the next global waypoint. + """ + + def __init__(self, parent_logger: RcutilsLogger): + """Initializes the LocalPath class.""" + self._logger = parent_logger.get_child(name="local_path") + self._ompl_path: Optional[OMPLPath] = None + self.waypoints: Optional[List[Tuple[float, float]]] = None + + def update_if_needed( + self, + gps: GPS, + ais_ships: AISShips, + global_path: Path, + filtered_wind_sensor: WindSensor, + planner: str, + ): + """Updates the OMPL path and waypoints. The path is updated if a new path is found. + + Args: + `gps` (GPS): GPS data. + `ais_ships` (AISShips): AIS ships data. + `global_path` (Path): Path to the destination. + `filtered_wind_sensor` (WindSensor): Wind data. + """ + state = LocalPathState(gps, ais_ships, global_path, filtered_wind_sensor, planner) + ompl_path = OMPLPath( + parent_logger=self._logger, + max_runtime=1.0, + local_path_state=state, + ) + if ompl_path.solved: + self._logger.info("Updating local path") + self._update(ompl_path) + + def _update(self, ompl_path: OMPLPath): + self._ompl_path = ompl_path + self.waypoints = self._ompl_path.get_waypoints() diff --git a/src/local_pathfinding/local_pathfinding/node_mock_global_path.py b/src/local_pathfinding/local_pathfinding/node_mock_global_path.py new file mode 100644 index 000000000..abb8a8696 --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/node_mock_global_path.py @@ -0,0 +1,234 @@ +"""Node loads in Sailbot's position via GET request, loads a global path from a csv file, +and posts the mock global path via a POST request. +The node is represented by the `MockGlobalPath` class.""" + +import os +import time + +import rclpy +from custom_interfaces.msg import GPS, HelperLatLon +from rclpy.node import Node + +from local_pathfinding.coord_systems import GEODESIC, meters_to_km +from local_pathfinding.global_path import ( + GPS_URL, + PATH_URL, + _interpolate_path, + calculate_interval_spacing, + generate_path, + get_path, + get_pos, + path_to_dict, + post_path, +) + +# Mock gps data to get things running until we have a running gps node +# TODO Remove when NET publishes GPS +MOCK_GPS = GPS(lat_lon=HelperLatLon(latitude=49.1154488073483, longitude=-125.95696431913618)) + + +def main(args=None): + rclpy.init(args=args) + mock_global_path = MockGlobalPath() + + rclpy.spin(node=mock_global_path) + + mock_global_path.destroy_node() + rclpy.shutdown() + + +class MockGlobalPath(Node): + """Stores and publishes the mock global path to the global_path topic. + + Subscribers: + gps_sub (Subscription): Subscribe to a `GPS` msg which contains the current GPS location of + sailbot. + + Publishers and their timers: + global_path_pub (Publisher): Publishes a `Path` msg containing the global path + global_path_timer (Timer): Periodically run the global path callback + + Attributes from subscribers: + gps (GPS): Data from the GPS sensor + + Attributes: + path_mod_tmstmp (Str): The modified timestamp of the global path csv file + file_path (Str): The filepath of the global path csv file + + Parameters: see [Sailbot ROS Parameter Configuration](https://github.com/UBCSailbot/sailbot_workspace/blob/main/src/global_launch/config/README.md) # noqa: E501 + for their documentation + """ + + def __init__(self): + super().__init__(node_name="mock_global_path") + + self.declare_parameters( + namespace="", + parameters=[ + ("pub_period_sec", rclpy.Parameter.Type.DOUBLE), + ("global_path_filepath", rclpy.Parameter.Type.STRING), + ("interval_spacing", rclpy.Parameter.Type.DOUBLE), + ("write", rclpy.Parameter.Type.BOOL), + ("gps_threshold", rclpy.Parameter.Type.DOUBLE), + ("force", rclpy.Parameter.Type.BOOL), + ], + ) + # get the publishing period parameter to use for callbacks + pub_period_sec = self.get_parameter("pub_period_sec").get_parameter_value().double_value + self.get_logger().debug(f"Got parameter: {pub_period_sec=}") + + # mock global path callback runs repeatedly on a timer + self.global_path_timer = self.create_timer( + timer_period_sec=pub_period_sec, + callback=self.global_path_callback, + ) + # Attributes + self.pos = MOCK_GPS.lat_lon + self.path_mod_tmstmp = None + self.file_path = None + self.period = pub_period_sec + + def check_pos(self): + """Get the gps data and check if the global path needs to be updated. + + If the position has changed by more than gps_threshold * interval_spacing since last step, + the force parameter set to true, bypassing any checks in the global_path_callback. + """ + self.get_logger().info( + f"Retreiving current position from {GPS_URL}", throttle_duration_sec=1 + ) + + pos = get_pos() + if pos is None: + return # error is logged in calling function + + position_delta = meters_to_km( + GEODESIC.inv( + lats1=self.pos.latitude, + lons1=self.pos.longitude, + lats2=pos.latitude, + lons2=pos.longitude, + )[2] + ) + gps_threshold = self.get_parameter("gps_threshold")._value + interval_spacing = self.get_parameter("interval_spacing")._value + + if position_delta > gps_threshold * interval_spacing: + self.get_logger().info( + f"GPS data changed by more than {gps_threshold*interval_spacing} km. Running " + "global path callback" + ) + + self.set_parameters([rclpy.Parameter("force", rclpy.Parameter.Type.BOOL, True)]) + + self.pos = pos + + # Timer callbacks + def global_path_callback(self): + """Check if the global path csv file has changed. If it has, the new path is published. + + This function also checks if the gps data has changed by more than + gps_threshold. If it has, the force parameter is set to true, bypassing any checks and + updating the path. + + Depending on the boolean value of the write parameter, each generated path may be written + to a new csv file in the same directory as the source csv file. + + Global path can be changed by modifying mock_global_path.csv or modifying the + global_path_filepath parameter. + + """ + + file_path = self.get_parameter("global_path_filepath")._value + + # check when global path was changed last + path_mod_tmstmp = time.ctime(os.path.getmtime(file_path)) + + self.check_pos() + + if self.pos is None: + self.log_no_pos() + return + + # check if the global path has been forced to update by a parameter change + force = self.get_parameter("force")._value + + # Only publish path if the path has changed or gps has changed by more than gps_threshold + if path_mod_tmstmp == self.path_mod_tmstmp and self.file_path == file_path and not force: + return + + self.get_logger().info( + f"Global path file is: {os.path.basename(file_path)}\n Reading path" + ) + global_path = get_path(file_path=file_path) + + pos = self.pos + + # obtain the actual distances between every waypoint in the global path + path_spacing = calculate_interval_spacing(pos, global_path.waypoints) + + # obtain desired interval spacing + interval_spacing = self.get_parameter("interval_spacing")._value + + # check if global path is just a destination point + if len(global_path.waypoints) < 2: + self.get_logger().info( + f"Generating new path from {pos.latitude:.4f}, {pos.longitude:.4f} to " + f"{global_path.waypoints[0].latitude:.4f}, " + f"{global_path.waypoints[0].longitude:.4f}" + ) + + write = self.get_parameter("write")._value + if write: + self.get_logger().info("Writing generated path to new file") + + msg = generate_path( + dest=global_path.waypoints[0], + interval_spacing=interval_spacing, + pos=pos, + write=write, + file_path=file_path, + ) + # Check if any waypoints are too far apart + elif max(path_spacing) > interval_spacing: + self.get_logger().info( + f"Some waypoints in the global path exceed the maximum interval spacing of" + f" {interval_spacing} km. Interpolating between waypoints and generating path" + ) + + write = self.get_parameter("write")._value + if write: + self.get_logger().info("Writing generated path to new file") + + msg = _interpolate_path( + global_path=global_path, + interval_spacing=interval_spacing, + pos=pos, + path_spacing=path_spacing, + write=write, + file_path=file_path, + ) + + else: + msg = global_path + + # post global path + if post_path(msg): + self.get_logger().info(f"Posting path to {PATH_URL}: {path_to_dict(msg)}") + self.set_parameters([rclpy.Parameter("force", rclpy.Parameter.Type.BOOL, False)]) + self.path_mod_tmstmp = path_mod_tmstmp + self.file_path = file_path + else: + self.log_failed_post() + + def log_no_pos(self): + self.get_logger().warn( + f"Failed to get position from {GPS_URL} will retry in {self.period} seconds." + ) + + def log_failed_post(self): + self.get_logger().warn(f"Failed to post path to {PATH_URL}") + + +if __name__ == "__main__": + main() diff --git a/src/local_pathfinding/local_pathfinding/node_navigate.py b/src/local_pathfinding/local_pathfinding/node_navigate.py new file mode 100644 index 000000000..bbf6d7493 --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/node_navigate.py @@ -0,0 +1,224 @@ +"""The main node of the local_pathfinding package, represented by the `Sailbot` class.""" + +import rclpy +from rclpy.node import Node + +import custom_interfaces.msg as ci +from local_pathfinding.local_path import LocalPath + + +def main(args=None): + rclpy.init(args=args) + sailbot = Sailbot() + + rclpy.spin(node=sailbot) + + sailbot.destroy_node() + rclpy.shutdown() + + +class Sailbot(Node): + """Stores, updates, and maintains the state of our autonomous sailboat. + + Subscribers: + ais_ships_sub (Subscription): Subscribe to a `AISShips` msg. + gps_sub (Subscription): Subscribe to a `GPS` msg. + global_path_sub (Subscription): Subscribe to a `Path` msg. + filtered_wind_sensor_sub (Subscription): Subscribe to a `WindSensor` msg. + + Publishers: + desired_heading_pub (Publisher): Publish the desired heading in a `DesiredHeading` msg. + lpath_data_pub (Publisher): Publish the local path in a `LPathData` msg. + + Publisher timers: + pub_period_sec (float): The period of the publisher timers. + desired_heading_timer (Timer): Call the desired heading callback function. + lpath_data_timer (Timer): Call the local path callback function. + + Attributes from subscribers: + ais_ships (ci.AISShips): Data from other boats. + gps (ci.GPS): Data from the GPS sensor. + global_path (ci.Path): Path that we are following. + filtered_wind_sensor (ci.WindSensor): Filtered data from the wind sensors. + + Attributes: + local_path (LocalPath): The path that `Sailbot` is following. + planner (str): The path planner that `Sailbot` is using. + """ + + def __init__(self): + super().__init__(node_name="navigate") + + self.declare_parameters( + namespace="", + parameters=[ + ("pub_period_sec", rclpy.Parameter.Type.DOUBLE), + ("path_planner", rclpy.Parameter.Type.STRING), + ], + ) + + # subscribers + self.ais_ships_sub = self.create_subscription( + msg_type=ci.AISShips, + topic="ais_ships", + callback=self.ais_ships_callback, + qos_profile=10, + ) + self.gps_sub = self.create_subscription( + msg_type=ci.GPS, topic="gps", callback=self.gps_callback, qos_profile=10 + ) + self.global_path_sub = self.create_subscription( + msg_type=ci.Path, + topic="global_path", + callback=self.global_path_callback, + qos_profile=10, + ) + self.filtered_wind_sensor_sub = self.create_subscription( + msg_type=ci.WindSensor, + topic="filtered_wind_sensor", + callback=self.filtered_wind_sensor_callback, + qos_profile=10, + ) + + # publishers + self.desired_heading_pub = self.create_publisher( + msg_type=ci.DesiredHeading, topic="desired_heading", qos_profile=10 + ) + self.lpath_data_pub = self.create_publisher( + msg_type=ci.LPathData, topic="local_path", qos_profile=10 + ) + + # publisher timers + self.pub_period_sec = ( + self.get_parameter("pub_period_sec").get_parameter_value().double_value + ) + self.get_logger().debug(f"Got parameter: {self.pub_period_sec=}") + self.desired_heading_timer = self.create_timer( + timer_period_sec=self.pub_period_sec, callback=self.desired_heading_callback + ) + self.lpath_data_timer = self.create_timer( + timer_period_sec=self.pub_period_sec, callback=self.lpath_data_callback + ) + + # attributes from subscribers + self.ais_ships = None + self.gps = None + self.global_path = None + self.filtered_wind_sensor = None + + # attributes + self.local_path = LocalPath(parent_logger=self.get_logger()) + self.planner = self.get_parameter("path_planner").get_parameter_value().string_value + self.get_logger().debug(f"Got parameter: {self.planner=}") + + # subscriber callbacks + + def ais_ships_callback(self, msg: ci.AISShips): + self.get_logger().debug(f"Received data from {self.ais_ships_sub.topic}: {msg}") + self.ais_ships = msg + + def gps_callback(self, msg: ci.GPS): + self.get_logger().debug(f"Received data from {self.gps_sub.topic}: {msg}") + self.gps = msg + + def global_path_callback(self, msg: ci.Path): + self.get_logger().debug(f"Received data from {self.global_path_sub.topic}: {msg}") + self.global_path = msg + + def filtered_wind_sensor_callback(self, msg: ci.WindSensor): + self.get_logger().debug(f"Received data from {self.filtered_wind_sensor_sub.topic}: {msg}") + self.filtered_wind_sensor = msg + + # publisher callbacks + + def desired_heading_callback(self): + """Get and publish the desired heading. + + Warn if not following the heading conventions in custom_interfaces/msg/HelperHeading.msg. + """ + self.update_params() + + desired_heading = self.get_desired_heading() + if desired_heading < 0 or 360 <= desired_heading: + self.get_logger().warning(f"Heading {desired_heading} not in [0, 360)") + + msg = ci.DesiredHeading() + msg.heading.heading = desired_heading + + self.desired_heading_pub.publish(msg) + self.get_logger().debug(f"Publishing to {self.desired_heading_pub.topic}: {msg}") + + def lpath_data_callback(self): + """Get and publish the local path.""" + + current_local_path = ci.Path(waypoints=self.local_path.waypoints) + + msg = ci.LPathData(local_path=current_local_path) + + self.lpath_data_pub.publish(msg) + self.get_logger().debug(f"Publishing to {self.lpath_data_pub.topic}: {msg}") + + # helper functions + + def get_desired_heading(self) -> float: + """Get the desired heading. + + Returns: + float: The desired heading if all subscribers are active, else a number that violates + the heading convention. + """ + if not self._all_subs_active(): + self._log_inactive_subs_warning() + return -1.0 + + self.local_path.update_if_needed( + self.gps, self.ais_ships, self.global_path, self.filtered_wind_sensor, self.planner + ) + + # TODO: create function to compute the heading from current position to next local waypoint + return 0.0 + + def update_params(self): + """Update instance variables that depend on parameters if they have changed.""" + pub_period_sec = self.get_parameter("pub_period_sec").get_parameter_value().double_value + if pub_period_sec != self.pub_period_sec: + self.get_logger().debug( + f"Updating pub period and timers from {self.pub_period_sec} to {pub_period_sec}" + ) + self.pub_period_sec = pub_period_sec + self.desired_heading_timer = self.create_timer( + timer_period_sec=self.pub_period_sec, callback=self.desired_heading_callback + ) + self.lpath_data_timer = self.create_timer( + timer_period_sec=self.pub_period_sec, callback=self.lpath_data_callback + ) + + planner = self.get_parameter("path_planner").get_parameter_value().string_value + if planner != self.planner: + self.get_logger().debug(f"Updating planner from {self.planner} to {planner}") + self.planner = planner + + def _all_subs_active(self) -> bool: + return True # TODO: this line is a placeholder, delete when mocks can be run + return self.ais_ships and self.gps and self.global_path and self.filtered_wind_sensor + + def _log_inactive_subs_warning(self): + """ + Logs a warning message for each inactive subscriber. + """ + inactive_subs = [] + if self.ais_ships_sub is None: + inactive_subs.append("ais_ships") + if self.gps_sub is None: + inactive_subs.append("gps") + if self.global_path_sub is None: + inactive_subs.append("global_path") + if self.filtered_wind_sensor_sub is None: + inactive_subs.append("filtered_wind_sensor") + if len(inactive_subs) == 0: + return + self._logger.warning("Inactive Subscribers: " + ", ".join(inactive_subs)) + + +if __name__ == "__main__": + main() diff --git a/src/local_pathfinding/local_pathfinding/objectives.py b/src/local_pathfinding/local_pathfinding/objectives.py new file mode 100644 index 000000000..835c4b700 --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/objectives.py @@ -0,0 +1,598 @@ +"""Our custom OMPL optimization objectives.""" + +import math +from enum import Enum, auto + +import numpy as np +from custom_interfaces.msg import HelperLatLon +from ompl import base as ob + +import local_pathfinding.coord_systems as cs + +# Upwind downwind cost multipliers +UPWIND_MULTIPLIER = 3000.0 +DOWNWIND_MULTIPLIER = 3000.0 + +# Upwind downwind constants +HIGHEST_UPWIND_ANGLE_RADIANS = math.radians(40.0) +LOWEST_DOWNWIND_ANGLE_RADIANS = math.radians(20.0) + + +BOATSPEEDS = np.array( + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0.4, 1.1, 3.2, 3.7, 2.8], + [0, 0.3, 1.9, 3.7, 9.3, 13.0, 9.2], + [0, 0.9, 3.7, 7.4, 14.8, 18.5, 13.0], + [0, 1.3, 5.6, 9.3, 18.5, 24.1, 18.5], + ] +) + +WINDSPEEDS = [0, 9.3, 18.5, 27.8, 37.0] # The row labels +ANGLES = [0, 20, 30, 45, 90, 135, 180] # The column labels + + +class DistanceMethod(Enum): + """Enumeration for distance objective methods""" + + EUCLIDEAN = auto() + LATLON = auto() + OMPL_PATH_LENGTH = auto() + + +class MinimumTurningMethod(Enum): + """Enumeration for minimum turning objective methods""" + + GOAL_HEADING = auto() + GOAL_PATH = auto() + HEADING_PATH = auto() + + +class SpeedObjectiveMethod(Enum): + """Enumeration for speed objective methods""" + + SAILBOT_PIECEWISE = auto() + SAILBOT_CONTINUOUS = auto() + SAILBOT_TIME = auto() + + +class Objective(ob.StateCostIntegralObjective): + """All of our optimization objectives inherit from this class. + + Notes: + - This class inherits from the OMPL class StateCostIntegralObjective: + https://ompl.kavrakilab.org/classompl_1_1base_1_1StateCostIntegralObjective.html + - Camelcase is used for functions that override OMPL functions, as that is their convention. + + Attributes: + space_information (StateSpacePtr): Contains all the information about + the space planning is done in. + """ + + def __init__(self, space_information): + super().__init__(si=space_information, enableMotionCostInterpolation=True) + self.space_information = space_information + + def motionCost(self, s1: ob.SE2StateSpace, s2: ob.SE2StateSpace) -> ob.Cost: + raise NotImplementedError + + +class DistanceObjective(Objective): + """Generates a distance objective function + + Attributes: + method (DistanceMethod): The method of the distance objective function + ompl_path_objective (ob.PathLengthOptimizationObjective): The OMPL path length objective. + Only defined if the method is OMPL path length. + reference (HelperLatLon): The XY origin when converting from latlon to XY. + Only defined if the method is latlon. + """ + + def __init__( + self, + space_information, + method: DistanceMethod, + reference=HelperLatLon(latitude=0.0, longitude=0.0), + ): + super().__init__(space_information) + self.method = method + if self.method == DistanceMethod.OMPL_PATH_LENGTH: + self.ompl_path_objective = ob.PathLengthOptimizationObjective(self.space_information) + elif self.method == DistanceMethod.LATLON: + self.reference = reference + + def motionCost(self, s1: ob.SE2StateSpace, s2: ob.SE2StateSpace) -> ob.Cost: + """Generates the distance between two points + + Args: + s1 (SE2StateInternal): The starting point of the local start state + s2 (SE2StateInternal): The ending point of the local goal state + + Returns: + ob.Cost: The distance between two points object + + Raises: + ValueError: If the distance method is not supported + """ + s1_xy = cs.XY(s1.getX(), s1.getY()) + s2_xy = cs.XY(s2.getX(), s2.getY()) + if self.method == DistanceMethod.EUCLIDEAN: + distance = DistanceObjective.get_euclidean_path_length_objective(s1_xy, s2_xy) + cost = ob.Cost(distance) + elif self.method == DistanceMethod.LATLON: + distance = DistanceObjective.get_latlon_path_length_objective( + s1_xy, s2_xy, self.reference + ) + cost = ob.Cost(distance) + elif self.method == DistanceMethod.OMPL_PATH_LENGTH: + cost = self.ompl_path_objective.motionCost(s1_xy, s2_xy) + else: + ValueError(f"Method {self.method} not supported") + return cost + + @staticmethod + def get_euclidean_path_length_objective(s1: cs.XY, s2: cs.XY) -> float: + """Generates the euclidean distance between two points + + Args: + s1 (cs.XY): The starting point of the local start state + s2 (cs.XY): The ending point of the local goal state + + Returns: + float: The euclidean distance between the two points + """ + return math.hypot(s2.y - s1.y, s2.x - s1.x) + + @staticmethod + def get_latlon_path_length_objective(s1: cs.XY, s2: cs.XY, reference: HelperLatLon) -> float: + """Generates the "great circle" distance between two points + + I am assuming that we are using the lat and long coordinates in determining the distance + between two points. + + Args: + s1 (cs.XY): The starting point of the local start state + s2 (cs.XY): The ending point of the local goal state + + Returns: + float: The great circle distance between two points + """ + latlon1 = cs.xy_to_latlon(reference, s1) + latlon2 = cs.xy_to_latlon(reference, s2) + + _, _, distance_m = cs.GEODESIC.inv( + latlon1.longitude, latlon1.latitude, latlon2.longitude, latlon2.latitude + ) + + return distance_m + + +class MinimumTurningObjective(Objective): + """Generates a minimum turning objective function + + Attributes: + goal (cs.XY): The goal position of the sailbot + heading (float): The heading of the sailbot in radians (-pi, pi] + method (MinimumTurningMethod): The method of the minimum turning objective function + """ + + def __init__( + self, + space_information, + simple_setup, + heading_degrees: float, + method: MinimumTurningMethod, + ): + super().__init__(space_information) + self.goal = cs.XY( + simple_setup.getGoal().getState().getX(), simple_setup.getGoal().getState().getY() + ) + assert -180 < heading_degrees <= 180 + self.heading = math.radians(heading_degrees) + self.method = method + + def motionCost(self, s1: ob.SE2StateSpace, s2: ob.SE2StateSpace) -> ob.Cost: + """Generates the turning cost between s1, s2, heading or the goal position + + Args: + s1 (SE2StateInternal): The starting point of the local start state + s2 (SE2StateInternal): The ending point of the local goal state + + Returns: + ob.Cost: The minimum turning angle in degrees + + Raises: + ValueError: If the minimum turning method is not supported + """ + s1_xy = cs.XY(s1.getX(), s1.getY()) + s2_xy = cs.XY(s2.getX(), s2.getY()) + if self.method == MinimumTurningMethod.GOAL_HEADING: + angle = self.goal_heading_turn_cost(s1_xy, self.goal, self.heading) + elif self.method == MinimumTurningMethod.GOAL_PATH: + angle = self.goal_path_turn_cost(s1_xy, s2_xy, self.goal) + elif self.method == MinimumTurningMethod.HEADING_PATH: + angle = self.heading_path_turn_cost(s1_xy, s2_xy, self.heading) + else: + ValueError(f"Method {self.method} not supported") + return ob.Cost(angle) + + @staticmethod + def goal_heading_turn_cost(s1: cs.XY, goal: cs.XY, heading: float) -> float: + """Determine the smallest turn angle between s1-goal and heading + + Args: + s1 (cs.XY): The starting point of the local start state + goal (cs.XY): The goal position of the sailbot + heading (float): The heading of the sailbot in radians (-pi, pi] + + Returns: + float: the turning angle from s2 to s1 in degrees + """ + # Calculate the true bearing of the goal from s1 + global_goal_direction = math.atan2(goal.x - s1.x, goal.y - s1.y) + + return MinimumTurningObjective.min_turn_angle(global_goal_direction, heading) + + @staticmethod + def goal_path_turn_cost(s1: cs.XY, s2: cs.XY, goal: cs.XY) -> float: + """Determine the smallest turn angle between s1-s2 and s1-goal + + Args: + s1 (cs.XY): The starting point of the local start state + s2 (cs.XY): The ending point of the local goal state + goal (cs.XY): The goal position of the sailbot + + Returns: + float: the turning angle from s2 to s1 in degrees + """ + # Calculate the true bearing of s2 from s1 + path_direction = math.atan2(s2.x - s1.x, s2.y - s1.y) + + # Calculate the true bearing of the goal from s1 + global_goal_direction = math.atan2(goal.x - s1.x, goal.y - s1.y) + + return MinimumTurningObjective.min_turn_angle(global_goal_direction, path_direction) + + @staticmethod + def heading_path_turn_cost(s1: cs.XY, s2: cs.XY, heading: float) -> float: + """Generates the turning cost between s1-s2 and heading of the sailbot + + Args: + s1 (cs.XY): The starting point of the local start state + s2 (cs.XY): The ending point of the local goal state + heading (float): The heading of the sailbot in radians (-pi, pi] + + Returns: + float: The minimum turning angle between s1-s2 and heading in degrees + """ + # Calculate the true bearing of s2 from s1 + path_direction = math.atan2(s2.x - s1.x, s2.y - s1.y) + + return MinimumTurningObjective.min_turn_angle(path_direction, heading) + + @staticmethod + def min_turn_angle(angle1: float, angle2: float) -> float: + """Calculates the minimum turning angle between two angles + + Args: + angle1 (float): The first angle in radians + angle2 (float): The second angle in radians + Must be bounded within 2pi radians of `angle1` + + Returns: + float: The minimum turning angle between the two angles in degrees + """ + # Calculate the uncorrected turn size [0, 2pi] + turn_size_bias = math.fabs(angle1 - angle2) + + # Correct the angle in between [0, pi] + if turn_size_bias > math.pi: + turn_size_unbias = 2 * math.pi - turn_size_bias + else: + turn_size_unbias = turn_size_bias + + return math.degrees(math.fabs(turn_size_unbias)) + + +class WindObjective(Objective): + """Generates a wind objective function + + Attributes: + wind_direction (float): The direction of the wind in radians (-pi, pi] + """ + + def __init__(self, space_information, wind_direction_degrees: float): + super().__init__(space_information) + assert -180 < wind_direction_degrees <= 180 + self.wind_direction = math.radians(wind_direction_degrees) + + def motionCost(self, s1: ob.SE2StateSpace, s2: ob.SE2StateSpace) -> ob.Cost: + """Generates the cost associated with the upwind and downwind directions of the boat in + relation to the wind. + + Args: + s1 (SE2StateInternal): The starting point of the local start state + s2 (SE2StateInternal): The ending point of the local goal state + + Returns: + ob.Cost: The cost of going upwind or downwind + """ + s1_xy = cs.XY(s1.getX(), s1.getY()) + s2_xy = cs.XY(s2.getX(), s2.getY()) + return ob.Cost(WindObjective.wind_direction_cost(s1_xy, s2_xy, self.wind_direction)) + + @staticmethod + def wind_direction_cost(s1: cs.XY, s2: cs.XY, wind_direction: float) -> float: + """Punishes the boat for going up/downwind. + + Args: + s1 (cs.XY): The starting point of the local start state + s2 (cs.XY): The ending point of the local goal state + wind_direction (float): The direction of the wind in radians (-pi, pi] + + Returns: + float: The cost of going upwind or downwind + """ + distance = math.hypot(s2.y - s1.y, s2.x - s1.x) + boat_direction_radians = math.atan2(s2.x - s1.x, s2.y - s1.y) + assert -math.pi <= boat_direction_radians <= math.pi + + if WindObjective.is_upwind(wind_direction, boat_direction_radians): + return UPWIND_MULTIPLIER * distance + elif WindObjective.is_downwind(wind_direction, boat_direction_radians): + return DOWNWIND_MULTIPLIER * distance + else: + return 0.0 + + @staticmethod + def is_upwind(wind_direction: float, boat_direction: float) -> bool: + """Determines whether the boat is upwind or not and its associated cost + + Args: + wind_direction (float): The true wind direction (radians). (-pi, pi] + boat_direction (float): The direction of the boat (radians). [-pi, pi] + + Returns: + bool: The cost associated with the upwind direction + """ + theta_min = wind_direction - HIGHEST_UPWIND_ANGLE_RADIANS + theta_max = wind_direction + HIGHEST_UPWIND_ANGLE_RADIANS + + return WindObjective.is_angle_between(theta_min, boat_direction, theta_max) + + @staticmethod + def is_downwind(wind_direction: float, boat_direction: float) -> bool: + """Generates the cost associated with the downwind direction + + Args: + wind_direction (float): The true wind direction (radians). (-pi, pi] + boat_direction_radians (float)): The direction of the boat (radians). [-pi, pi] + + Returns: + bool: The cost associated with the downwind direction + """ + downwind_wind_direction = (wind_direction + math.pi) % (2 * math.pi) + + theta_min = downwind_wind_direction - LOWEST_DOWNWIND_ANGLE_RADIANS + + theta_max = downwind_wind_direction + LOWEST_DOWNWIND_ANGLE_RADIANS + + return WindObjective.is_angle_between(theta_min, boat_direction, theta_max) + + @staticmethod + def is_angle_between(first_angle: float, middle_angle: float, second_angle: float) -> bool: + """Determines whether an angle is between two other angles + + Args: + first_angle (float): The first bounding angle in radians + middle_angle (float): The angle in question in radians + second_angle (float): The second bounding angle in radians + + Returns: + bool: True when `middle_angle` is not in the reflex angle of + `first_angle` and `second_angle`, false otherwise. + """ + # Bound the angles to [0, 2pi) + first_angle = first_angle % (2 * math.pi) + middle_angle = middle_angle % (2 * math.pi) + second_angle = second_angle % (2 * math.pi) + + if first_angle <= second_angle: + if second_angle - math.pi == first_angle: + # Assume all angles are between first and second + return middle_angle != first_angle and middle_angle != second_angle + elif second_angle - math.pi < first_angle: + return middle_angle > first_angle and middle_angle < second_angle + else: + return middle_angle < first_angle or middle_angle > second_angle + else: + return WindObjective.is_angle_between(second_angle, middle_angle, first_angle) + + +class SpeedObjective(Objective): + """Generates a speed objective function + + Attributes: + wind_direction (float): The direction of the wind in radians (-pi, pi] + wind_speed (float): The speed of the wind in m/s + """ + + def __init__( + self, + space_information, + heading_direction: float, + wind_direction: float, + wind_speed: float, + method: SpeedObjectiveMethod, + ): + super().__init__(space_information) + assert -180 < wind_direction <= 180 + self.wind_direction = math.radians(wind_direction) + + assert -180 < heading_direction <= 180 + self.heading_direction = math.radians(heading_direction) + + self.wind_speed = wind_speed + self.method = method + + def motionCost(self, s1: ob.SE2StateSpace, s2: ob.SE2StateSpace) -> ob.Cost: + """Generates the cost associated with the speed of the boat. + + Args: + s1 (SE2StateInternal): The starting point of the local start state + s2 (SE2StateInternal): The ending point of the local goal state + + Returns: + ob.Cost: The cost of going upwind or downwind + """ + + s1_xy = cs.XY(s1.getX(), s1.getY()) + s2_xy = cs.XY(s2.getX(), s2.getY()) + + sailbot_speed = self.get_sailbot_speed( + self.heading_direction, self.wind_direction, self.wind_speed + ) + + if sailbot_speed == 0: + return ob.Cost(10000) + + if self.method == SpeedObjectiveMethod.SAILBOT_TIME: + distance = DistanceObjective.get_euclidean_path_length_objective(s1_xy, s2_xy) + time = distance / sailbot_speed + + cost = ob.Cost(time) + + elif self.method == SpeedObjectiveMethod.SAILBOT_PIECEWISE: + cost = ob.Cost(self.get_piecewise_cost(sailbot_speed)) + elif self.method == SpeedObjectiveMethod.SAILBOT_CONTINUOUS: + cost = ob.Cost(self.get_continuous_cost(sailbot_speed)) + else: + ValueError(f"Method {self.method} not supported") + return cost + + @staticmethod + def get_sailbot_speed(heading: float, wind_direction: float, wind_speed: float) -> float: + # Get the sailing angle: [0, 180] + sailing_angle = abs(heading - wind_direction) + sailing_angle = min(sailing_angle, 360 - sailing_angle) + + # Find the nearest windspeed values above and below the true windspeed + lower_windspeed_index = max([i for i, ws in enumerate(WINDSPEEDS) if ws <= wind_speed]) + upper_windspeed_index = ( + lower_windspeed_index + 1 + if lower_windspeed_index < len(WINDSPEEDS) - 1 + else lower_windspeed_index + ) + + # Find the nearest angle values above and below the sailing angle + lower_angle_index = max([i for i, ang in enumerate(ANGLES) if ang <= sailing_angle]) + upper_angle_index = ( + lower_angle_index + 1 if lower_angle_index < len(ANGLES) - 1 else lower_angle_index + ) + + # Find the maximum angle and maximum windspeed based on the actual data in the table + max_angle = max(ANGLES) + max_windspeed = max(WINDSPEEDS) + + # Handle the case of maximum angle (use the dynamic max_angle) + if upper_angle_index == len(ANGLES) - 1: + lower_angle_index = ANGLES.index(max_angle) - 1 + upper_angle_index = ANGLES.index(max_angle) + + # Handle the case of the maximum windspeed (use the dynamic max_windspeed) + if upper_windspeed_index == len(WINDSPEEDS) - 1: + lower_windspeed_index = WINDSPEEDS.index(max_windspeed) - 1 + upper_windspeed_index = WINDSPEEDS.index(max_windspeed) + + # Perform linear interpolation + lower_windspeed = WINDSPEEDS[lower_windspeed_index] + upper_windspeed = WINDSPEEDS[upper_windspeed_index] + lower_angle = ANGLES[lower_angle_index] + upper_angle = ANGLES[upper_angle_index] + + boat_speed_lower = BOATSPEEDS[lower_windspeed_index][lower_angle_index] + boat_speed_upper = BOATSPEEDS[upper_windspeed_index][lower_angle_index] + + interpolated_1 = boat_speed_lower + (wind_speed - lower_windspeed) * ( + boat_speed_upper - boat_speed_lower + ) / (upper_windspeed - lower_windspeed) + + boat_speed_lower = BOATSPEEDS[lower_windspeed_index][upper_angle_index] + boat_speed_upper = BOATSPEEDS[upper_windspeed_index][upper_angle_index] + + interpolated_2 = boat_speed_lower + (wind_speed - lower_windspeed) * ( + boat_speed_upper - boat_speed_lower + ) / (upper_windspeed - lower_windspeed) + + interpolated_value = interpolated_1 + (sailing_angle - lower_angle) * ( + interpolated_2 - interpolated_1 + ) / (upper_angle - lower_angle) + + return interpolated_value + + @staticmethod + def get_piecewise_cost(speed: float) -> float: + """Generates the cost associated with the speed of the boat. + + Args: + speed (float): The speed of the boat in m/s + """ + + if speed < 5: + return 5 + elif 5 < speed < 10: + return 10 + elif 10 < speed < 15: + return 20 + elif 15 < speed < 20: + return 50 + else: + return 10000 + + @staticmethod + def get_continuous_cost(speed: float) -> float: + """Generates the cost associated with the speed of the boat. + + Args: + speed (float): The speed of the boat in m/s + """ + try: + cost = abs(1 / math.sin(math.pi * speed / 25) - 0.5) + return min(10000, cost) + except ZeroDivisionError: + return 10000 + + +def get_sailing_objective( + space_information, + simple_setup, + heading_degrees: float, + wind_direction_degrees: float, + wind_speed: float, +) -> ob.OptimizationObjective: + objective = ob.MultiOptimizationObjective(si=space_information) + objective.addObjective( + objective=DistanceObjective(space_information, DistanceMethod.LATLON), + weight=1.0, + ) + objective.addObjective( + objective=MinimumTurningObjective( + space_information, simple_setup, heading_degrees, MinimumTurningMethod.GOAL_HEADING + ), + weight=100.0, + ) + objective.addObjective( + objective=WindObjective(space_information, wind_direction_degrees), weight=1.0 + ) + objective.addObjective( + objective=SpeedObjective( + space_information, + heading_degrees, + wind_direction_degrees, + wind_speed, + SpeedObjectiveMethod.SAILBOT_TIME, + ), + weight=1.0, + ) + + return objective diff --git a/src/local_pathfinding/local_pathfinding/obstacles.py b/src/local_pathfinding/local_pathfinding/obstacles.py new file mode 100644 index 000000000..4df7c3c73 --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/obstacles.py @@ -0,0 +1,224 @@ +"""Describes obstacles which the Sailbot must avoid: Boats and Land""" + +import math +from typing import Optional + +import numpy as np +from custom_interfaces.msg import HelperAISShip, HelperLatLon +from shapely.affinity import affine_transform +from shapely.geometry import Point, Polygon + +from local_pathfinding.coord_systems import XY, latlon_to_xy, meters_to_km + +# Constants +PROJ_TIME_NO_COLLISION = 3 # hours +COLLISION_ZONE_SAFETY_BUFFER = 0.5 # km +COLLISION_ZONE_STRETCH_FACTOR = 1.5 # This factor changes the scope/width of the collision cone + + +class Obstacle: + """This class describes general obstacle objects which are + anything which the sailbot must avoid. + + Attributes: + reference (HelperLatLon): Lat and lon position of the next global waypoint. + sailbot_position (XY): Lat and lon position of SailBot. + sailbot_speed (float): Speed of the SailBot in kmph. + collision_zone (Optional[Polygon]): Shapely polygon representing the + obstacle's collision zone. Shape depends on the child class. + """ + + def __init__( + self, reference: HelperLatLon, sailbot_position: HelperLatLon, sailbot_speed: float + ): + self.reference = reference + self.sailbot_position_latlon = sailbot_position + self.sailbot_position = latlon_to_xy(self.reference, self.sailbot_position_latlon) + self.sailbot_speed = sailbot_speed + + # Defined later by the child class + self.collision_zone = None + + def is_valid(self, point: XY) -> bool: + """Checks if a point is contained the obstacle's collision zone. + + Args: + point (HelperLatLon): Point representing the state point to be checked. + + Returns: + bool: True if the point is not within the obstacle's collision zone, false otherwise. + + Raises: + ValueError: If the collision zone has not been initialized. + """ + if self.collision_zone is None: + raise ValueError("Collision zone has not been initialized") + + # contains() requires a shapely Point object as an argument + point = Point(*point) + + return not self.collision_zone.contains(point) + + def update_collision_zone(self, collision_zone: Polygon, offset: XY, angle: float): + """Updates the collision zone of the obstacle. Called by the child classes. + + Args: + collision_zone (Polygon): Shapely Polygon representing the obstacle's collision zone. + offset (XY): position of the collision zone relative to the reference point. + angle (float): rotation angle of the collision zone in degrees. + """ + dx, dy = offset + angle_rad = math.radians(angle) + sin_theta = math.sin(angle_rad) + cos_theta = math.cos(angle_rad) + + # coefficient matrix for the 2D affine transformation of the collision zone + transformation = np.array([cos_theta, -sin_theta, sin_theta, cos_theta, dx, dy]) + + collision_zone = affine_transform(collision_zone, transformation) + + self.collision_zone = collision_zone.buffer(COLLISION_ZONE_SAFETY_BUFFER, join_style=2) + + def update_sailbot_data(self, sailbot_position: HelperLatLon, sailbot_speed: float): + """Updates the sailbot's position and speed. + + Args: + sailbot_position (HelperLatLon): Position of the SailBot. + sailbot_speed (float): Speed of the SailBot in kmph. + """ + self.sailbot_position_latlon = sailbot_position + self.sailbot_position = latlon_to_xy(self.reference, sailbot_position) + self.sailbot_speed = sailbot_speed + + def update_reference_point(self, reference: HelperLatLon): + """Updates the reference point. + + Args: + reference (HelperLatLon): Position of the updated global waypoint. + """ + self.reference = reference + self.sailbot_position = latlon_to_xy(self.reference, self.sailbot_position_latlon) + + if isinstance(self, Boat): + # regenerate collision zone with updated reference point + self.update_boat_collision_zone() + + +class Boat(Obstacle): + """Describes boat objects which Sailbot must avoid. + Also referred to target ships or boat obstacles. + + Attributes: + ais_ship (HelperAISShip): AIS Ship message containing information about the boat. + width (float): Width of the boat in km. + length (float): Length of the boat in km. + """ + + def __init__( + self, + reference: HelperLatLon, + sailbot_position: HelperLatLon, + sailbot_speed: float, + ais_ship: HelperAISShip, + ): + super().__init__(reference, sailbot_position, sailbot_speed) + + self.ais_ship = ais_ship + self.width = meters_to_km(self.ais_ship.width.dimension) + self.length = meters_to_km(self.ais_ship.length.dimension) + self.update_boat_collision_zone() + + def update_boat_collision_zone(self, ais_ship: Optional[HelperAISShip] = None): + """Sets or regenerates a Shapely Polygon that represents the boat's collision zone, + which is shaped like a cone. + + Args: + ais_ship (Optional[HelperAISShip]): AIS Ship message containing boat information. + """ + if ais_ship is not None: + if ais_ship.id != self.ais_ship.id: + raise ValueError("Argument AIS Ship ID does not match this Boat instance's ID") + + # Ensure ais_ship instance variable is the most up to date one + self.ais_ship = ais_ship + + # Store as local variables for performance + ais_ship = self.ais_ship + width = self.width + length = self.length + + # coordinates of the center of the boat + position = latlon_to_xy(self.reference, ais_ship.lat_lon) + + # Course over ground of the boat + cog = ais_ship.cog.heading + + # Calculate distance the boat will travel before soonest possible collision with Sailbot + projected_distance = self.calculate_projected_distance() + + # TODO This feels too arbitrary, maybe will incorporate ROT at a later time + collision_zone_width = projected_distance * COLLISION_ZONE_STRETCH_FACTOR * width + + # Points of the boat collision cone polygon before rotation and centred at the origin + boat_collision_zone = Polygon( + [ + [-width / 2, -length / 2], + [-collision_zone_width, length / 2 + projected_distance], + [collision_zone_width, length / 2 + projected_distance], + [width / 2, -length / 2], + ] + ) + + self.update_collision_zone(boat_collision_zone, position, -cog) + + def calculate_projected_distance(self) -> float: + """Calculates the distance the boat obstacle will travel before collision, if + Sailbot moves directly towards the soonest possible collision point at its current speed. + The system is modeled by two parametric lines extending from the positions of the boat + obstacle and sailbot respectively, in 2D space. These lines may intersect at some specific + point and time. + + The vector that represents the Sailbot's velocity is free to point at the soonest possible + collision point, but its magnitude is constrained. + + An in-depth explanation for this function can be found here: + https://ubcsailbot.atlassian.net/wiki/spaces/prjt22/pages/1881145358/Obstacle+Class+Planning + + Returns: + float: Distance the boat will travel before collision or the max projection distance + if a collision is not possible. + """ + position = latlon_to_xy(self.reference, self.ais_ship.lat_lon) + + # vector components of the boat's speed over ground + cog_rad = math.radians(self.ais_ship.cog.heading) + v1 = self.ais_ship.sog.speed * math.sin(cog_rad) + v2 = self.ais_ship.sog.speed * math.cos(cog_rad) + + # coordinates of the boat + a, b = position + + # coordinates of Sailbot + c, d = self.sailbot_position + + quadratic_coefficients = np.array( + [ + v1**2 + v2**2 - (self.sailbot_speed**2), + 2 * (v1 * (a - c) + v2 * (b - d)), + (a - c) ** 2 + (b - d) ** 2, + ] + ) + + # The solution to the quadratic formula is the time until the boats collide + quad_roots = np.roots(quadratic_coefficients) + + # filter out only positive and real roots + quad_roots = [i for i in quad_roots if i >= 0 and i.imag == 0] + + if len(quad_roots) == 0: + # Sailbot and this Boat will never collide + return PROJ_TIME_NO_COLLISION * self.ais_ship.sog.speed + + # Use the smaller positive time, if there is one + t = min(quad_roots) + return t * self.ais_ship.sog.speed diff --git a/src/local_pathfinding/local_pathfinding/ompl_path.py b/src/local_pathfinding/local_pathfinding/ompl_path.py new file mode 100644 index 000000000..37d52a62b --- /dev/null +++ b/src/local_pathfinding/local_pathfinding/ompl_path.py @@ -0,0 +1,248 @@ +"""The local_pathfinding<->OMPL interface, represented by the OMPLPath class. + +OMPL is written in C++, but Python bindings were generated to interface with OMPL in Python. +VS Code currently can't read these bindings, so LSP features (autocomplete, go to definition, etc. +won't work). The C++ API is documented on the OMPL website: +https://ompl.kavrakilab.org/api_overview.html. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Tuple, Type + +from custom_interfaces.msg import HelperLatLon +from ompl import base as ob +from ompl import geometric as og +from ompl import util as ou +from rclpy.impl.rcutils_logger import RcutilsLogger + +import local_pathfinding.coord_systems as cs +from local_pathfinding.objectives import get_sailing_objective + +if TYPE_CHECKING: + from local_pathfinding.local_path import LocalPathState + +# OMPL logging: only log warnings and above +ou.setLogLevel(ou.LOG_WARN) + + +class OMPLPathState: + def __init__(self, local_path_state: LocalPathState, logger: RcutilsLogger): + # TODO: derive OMPLPathState attributes from local_path_state + self.heading_direction = 45.0 + self.wind_direction = 10.0 + self.wind_speed = 1.0 + + self.state_domain = (-1, 1) + self.state_range = (-1, 1) + self.start_state = (0.5, 0.4) + self.goal_state = (0.5, -0.4) + + self.reference_latlon = ( + local_path_state.global_path[-1] + if local_path_state and len(local_path_state.global_path) > 0 + else HelperLatLon(latitude=0.0, longitude=0.0) + ) + + if local_path_state: + planner = local_path_state.planner + supported_planner, _ = get_planner_class(planner) + if planner != supported_planner: + logger.error( + f"Planner {planner} is not implemented, defaulting to {supported_planner}" + ) + self.planner = supported_planner + + +class OMPLPath: + """Represents the general OMPL Path. + + Attributes + _logger (RcutilsLogger): ROS logger of this class. + _simple_setup (og.SimpleSetup): OMPL SimpleSetup object. + solved (bool): True if the path is a solution to the OMPL query, else false. + """ + + def __init__( + self, + parent_logger: RcutilsLogger, + max_runtime: float, + local_path_state: LocalPathState, + ): + """Initialize the OMPLPath Class. Attempt to solve for a path. + + Args: + parent_logger (RcutilsLogger): Logger of the parent class. + max_runtime (float): Maximum amount of time in seconds to look for a solution path. + local_path_state (LocalPathState): State of Sailbot. + """ + self._logger = parent_logger.get_child(name="ompl_path") + self.state = OMPLPathState(local_path_state, self._logger) + self._simple_setup = self._init_simple_setup() + self.solved = self._simple_setup.solve(time=max_runtime) # time is in seconds + + # TODO: play around with simplifySolution() + # if self.solved: + # # try to shorten the path + # simple_setup.simplifySolution() + + def get_cost(self): + """Get the cost of the path generated. + + Raises: + NotImplementedError: Method or function hasn't been implemented yet. + """ + raise NotImplementedError + + def get_waypoints(self) -> List[HelperLatLon]: + """Get a list of waypoints for the boat to follow. + + Returns: + list: A list of tuples representing the x and y coordinates of the waypoints. + Output an empty list and print a warning message if path not solved. + """ + if not self.solved: + self._logger.warning("Trying to get the waypoints of an unsolved OMPLPath") + return [] + + solution_path = self._simple_setup.getSolutionPath() + + waypoints = [] + + for state in solution_path.getStates(): + waypoint_XY = cs.XY(state.getX(), state.getY()) + waypoint_latlon = cs.xy_to_latlon(self.state.reference_latlon, waypoint_XY) + waypoints.append( + HelperLatLon( + latitude=waypoint_latlon.latitude, longitude=waypoint_latlon.longitude + ) + ) + + return waypoints + + def update_objectives(self): + """Update the objectives on the basis of which the path is optimized. + Raises: + NotImplementedError: Method or function hasn't been implemented yet. + """ + raise NotImplementedError + + def _init_simple_setup(self) -> og.SimpleSetup: + """Initialize and configure the OMPL SimpleSetup object. + + Returns: + og.SimpleSetup: Encapsulates the various objects necessary to solve a geometric or + control query in OMPL. + """ + # create an SE2 state space: rotation and translation in a plane + space = ob.SE2StateSpace() + + # set the bounds of the state space + bounds = ob.RealVectorBounds(dim=2) + x_min, x_max = self.state.state_domain + y_min, y_max = self.state.state_range + bounds.setLow(index=0, value=x_min) + bounds.setLow(index=1, value=y_min) + bounds.setHigh(index=0, value=x_max) + bounds.setHigh(index=1, value=y_max) + self._logger.debug( + "state space bounds: " + f"x=[{bounds.low[0]}, {bounds.high[0]}]; " + f"y=[{bounds.low[1]}, {bounds.high[1]}]" + ) + bounds.check() # check if bounds are valid + space.setBounds(bounds) + + # create a simple setup object + simple_setup = og.SimpleSetup(space) + simple_setup.setStateValidityChecker(ob.StateValidityCheckerFn(is_state_valid)) + + # set the goal and start states of the simple setup object + start = ob.State(space) + goal = ob.State(space) + start_x, start_y = self.state.start_state + goal_x, goal_y = self.state.goal_state + start().setXY(start_x, start_y) + goal().setXY(goal_x, goal_y) + self._logger.debug( + "start and goal state: " + f"start=({start().getX()}, {start().getY()}); " + f"goal=({goal().getX()}, {goal().getY()})" + ) + simple_setup.setStartAndGoalStates(start, goal) + + # Constructs a space information instance for this simple setup + space_information = simple_setup.getSpaceInformation() + + # set the optimization objective of the simple setup object + # TODO: implement and add optimization objective here + + objective = get_sailing_objective( + space_information, + simple_setup, + self.state.heading_direction, + self.state.wind_direction, + self.state.wind_speed, + ) + simple_setup.setOptimizationObjective(objective) + + # set the planner of the simple setup object + _, planner_class = get_planner_class(self.state.planner) + planner = planner_class(space_information) + simple_setup.setPlanner(planner) + + return simple_setup + + +def is_state_valid(state: ob.SE2StateSpace) -> bool: + """Evaluate a state to determine if the configuration collides with an environment obstacle. + + Args: + state (ob.SE2StateSpace): State to check. + + Returns: + bool: True if state is valid, else false. + """ + # TODO: implement obstacle avoidance here + # note: `state` is of type `SE2StateInternal`, so we don't need to use the `()` operator. + return state.getX() < 0.6 + + +def get_planner_class(planner: str) -> Tuple[str, Type[ob.Planner]]: + """Choose the planner to use for the OMPL query. + + Args: + planner (str): Name of the planner to use. + + Returns: + Tuple[str, Type[ob.Planner]]: The name and class of the planner to use for the OMPL query, + defaults to RRT* if `planner` is not implemented in this function. + """ + match planner.lower(): + case "bitstar": + return planner, og.BITstar + case "bfmtstar": + return planner, og.BFMT + case "fmtstar": + return planner, og.FMT + case "informedrrtstar": + return planner, og.InformedRRTstar + case "lazylbtrrt": + return planner, og.LazyLBTRRT + case "lazyprmstar": + return planner, og.LazyPRMstar + case "lbtrrt": + return planner, og.LBTRRT + case "prmstar": + return planner, og.PRMstar + case "rrtconnect": + return planner, og.RRTConnect + case "rrtsharp": + return planner, og.RRTsharp + case "rrtstar": + return planner, og.RRTstar + case "rrtxstatic": + return planner, og.RRTXstatic + case "sorrtstar": + return planner, og.SORRTstar + case _: + return "rrtstar", og.RRTstar diff --git a/src/local_pathfinding/package.xml b/src/local_pathfinding/package.xml new file mode 100644 index 000000000..955fcff39 --- /dev/null +++ b/src/local_pathfinding/package.xml @@ -0,0 +1,28 @@ + + + + local_pathfinding + 0.0.0 + UBC Sailbot's local pathfinding ROS package + Patrick Creighton + MIT + + + custom_interfaces + rclpy + + + python3-numpy + python3-pyproj + python3-shapely + + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/src/local_pathfinding/requirements.txt b/src/local_pathfinding/requirements.txt new file mode 100644 index 000000000..a4bbb4e84 --- /dev/null +++ b/src/local_pathfinding/requirements.txt @@ -0,0 +1,8 @@ +# packages that aren't required on the main computer in production +# install them with pip3 install -r requirements.txt + +# global_paths/path_builder/path_builder.py +flask + +# test/test_obstacles.py +plotly diff --git a/src/local_pathfinding/resource/local_pathfinding b/src/local_pathfinding/resource/local_pathfinding new file mode 100644 index 000000000..e69de29bb diff --git a/src/local_pathfinding/setup.cfg b/src/local_pathfinding/setup.cfg new file mode 100644 index 000000000..58c02e8ff --- /dev/null +++ b/src/local_pathfinding/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/local_pathfinding +[install] +install_scripts=$base/lib/local_pathfinding diff --git a/src/local_pathfinding/setup.py b/src/local_pathfinding/setup.py new file mode 100644 index 000000000..c872a702b --- /dev/null +++ b/src/local_pathfinding/setup.py @@ -0,0 +1,30 @@ +import os +from glob import glob + +from setuptools import setup + +package_name = "local_pathfinding" + +setup( + name=package_name, + version="0.0.0", + packages=[package_name], + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + package_name]), + ("share/" + package_name, ["package.xml"]), + (os.path.join("share", package_name), glob("launch/*_launch.py")), + ], + install_requires=["setuptools"], + zip_safe=True, + maintainer="Patrick Creighton", + maintainer_email="software@ubcsailbot.org", + description="UBC Sailbot's local pathfinding ROS package", + license="MIT", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "navigate = local_pathfinding.node_navigate:main", + "mock_global_path = local_pathfinding.node_mock_global_path:main", + ], + }, +) diff --git a/src/local_pathfinding/test/post_server.py b/src/local_pathfinding/test/post_server.py new file mode 100644 index 000000000..7681b63e3 --- /dev/null +++ b/src/local_pathfinding/test/post_server.py @@ -0,0 +1,55 @@ +""" +This is a basic http server to handle POST requests from the global path module until the NET +endpoint is implemented. + +It receives a JSON payload with a list of waypoints and prints them to the console. +""" +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class CustomRequestHandler(BaseHTTPRequestHandler): + def _set_response(self, status_code=200, content_type="application/json"): + self.send_response(status_code) + self.send_header("Content-type", content_type) + self.end_headers() + + def do_POST(self): + content_length = int(self.headers["Content-Length"]) + post_data = self.rfile.read(content_length) + data = json.loads(post_data.decode("utf-8")) + + # Process the data as needed + waypoints = data.get("waypoints", []) + + # For now, just print the waypoints + print("Received waypoints:", waypoints) + + self._set_response(200) + self.wfile.write( + json.dumps({"message": "Global path received successfully"}).encode("utf-8") + ) + + +def run_server(port=8081) -> HTTPServer: + server_address = ("localhost", port) + httpd = HTTPServer(server_address, CustomRequestHandler) + + def run(): + print(f"Server running on http://localhost:{port}") + httpd.serve_forever() + + # Start the server in a separate thread + server_thread = threading.Thread(target=run) + server_thread.start() + + return httpd + + +def shutdown_server(httpd: HTTPServer): + httpd.shutdown() + + +if __name__ == "__main__": + run_server() diff --git a/src/local_pathfinding/test/test_coord_systems.py b/src/local_pathfinding/test/test_coord_systems.py new file mode 100644 index 000000000..edd454a89 --- /dev/null +++ b/src/local_pathfinding/test/test_coord_systems.py @@ -0,0 +1,101 @@ +import math + +import pytest +from custom_interfaces.msg import HelperLatLon + +import local_pathfinding.coord_systems as coord_systems + + +@pytest.mark.parametrize( + "cartesian,true_bearing", + [ + (0.0, 90.0), + (90.0, 0.0), + (180.0, 270.0), + (270.0, 180.0), + ], +) +def test_cartesian_to_true_bearing(cartesian: float, true_bearing: float): + assert coord_systems.cartesian_to_true_bearing(cartesian) == pytest.approx( + true_bearing + ), "incorrect angle conversion" + + +@pytest.mark.parametrize( + "meters,km", + [(0.0, 0.0), (30, 0.03), (500, 0.5), (-30.5, -0.0305), (-0.0, 0.0)], +) +def test_meters_to_km(meters: float, km: float): + assert coord_systems.meters_to_km(meters) == pytest.approx(km), "incorrect distance conversion" + + +@pytest.mark.parametrize( + "km,meters", + [(0.0, 0.0), (0.03, 30), (0.5, 500), (-0.0305, -30.5), (-0.0, 0.0)], +) +def test_km_to_meters(km: float, meters: float): + assert coord_systems.km_to_meters(km) == pytest.approx(meters), "incorrect distance conversion" + + +@pytest.mark.parametrize( + "ref_lat,ref_lon,true_bearing_deg,dist_km", + [ + (30.0, -123.0, 0.00, 30.0), + (30.0, -123.0, 45.0, 30.0), + (30.0, -123.0, 90.0, 30.0), + (60.0, -123.0, 0.00, 30.0), + (60.0, -123.0, 45.0, 30.0), + (60.0, -123.0, 90.0, 30.0), + ], +) +def test_latlon_to_xy(ref_lat: float, ref_lon: float, true_bearing_deg: float, dist_km: float): + # create inputs + reference = HelperLatLon(latitude=ref_lat, longitude=ref_lon) + lon, lat, _ = coord_systems.GEODESIC.fwd( + lons=ref_lon, lats=ref_lat, az=true_bearing_deg, dist=dist_km * 1000 + ) + latlon = HelperLatLon(latitude=lat, longitude=lon) + + # create expected output + true_bearing = math.radians(true_bearing_deg) + xy = coord_systems.XY( + x=dist_km * math.sin(true_bearing), + y=dist_km * math.cos(true_bearing), + ) + + assert coord_systems.latlon_to_xy(reference, latlon) == pytest.approx( + xy + ), "incorrect coordinate conversion" + + +@pytest.mark.parametrize( + "ref_lat,ref_lon,true_bearing_deg,dist_km", + [ + (30.0, -123.0, 0.00, 30.0), + (30.0, -123.0, 45.0, 30.0), + (30.0, -123.0, 90.0, 30.0), + (60.0, -123.0, 0.00, 30.0), + (60.0, -123.0, 45.0, 30.0), + (60.0, -123.0, 90.0, 30.0), + (60.0, -123.0, -120.0, 30.0), + ], +) +def test_xy_to_latlon(ref_lat: float, ref_lon: float, true_bearing_deg: float, dist_km: float): + # create inputs + true_bearing = math.radians(true_bearing_deg) + xy = coord_systems.XY( + x=dist_km * math.sin(true_bearing), + y=dist_km * math.cos(true_bearing), + ) + + # create expected output + reference = HelperLatLon(latitude=ref_lat, longitude=ref_lon) + lon, lat, _ = coord_systems.GEODESIC.fwd( + lons=ref_lon, lats=ref_lat, az=true_bearing_deg, dist=dist_km * 1000 + ) + latlon = HelperLatLon(latitude=lat, longitude=lon) + + converted_latlon = coord_systems.xy_to_latlon(reference, xy) + assert (converted_latlon.latitude, converted_latlon.longitude) == pytest.approx( + (latlon.latitude, latlon.longitude) + ), "incorrect coordinate conversion" diff --git a/src/local_pathfinding/test/test_global_path.py b/src/local_pathfinding/test/test_global_path.py new file mode 100644 index 000000000..ceca31e7f --- /dev/null +++ b/src/local_pathfinding/test/test_global_path.py @@ -0,0 +1,359 @@ +import os + +import post_server +import pytest +from custom_interfaces.msg import HelperLatLon, Path + +from local_pathfinding.coord_systems import GEODESIC, meters_to_km +from local_pathfinding.global_path import ( + _interpolate_path, + calculate_interval_spacing, + generate_path, + get_most_recent_file, + get_path, + get_pos, + interpolate_path, + path_to_dict, + post_path, + write_to_file, +) + + +# ------------------------- TEST _INTERPOLATE_PATH ------------------------- +@pytest.mark.parametrize( + "pos,global_path,interval_spacing", + [ + ( + HelperLatLon(latitude=47.00, longitude=122.00), + Path( + waypoints=[ + HelperLatLon(latitude=48.00, longitude=123.00), + HelperLatLon(latitude=49.00, longitude=124.00), + HelperLatLon(latitude=50.00, longitude=125.00), + ] + ), + 30.0, + ) + ], +) +def test__interpolate_path( + pos: HelperLatLon, + global_path: Path, + interval_spacing: float, +): + """Test the _interpolate_path method of MockGlobalPath. + + Args: + global_path (HelperLatLon): The global path. + interval_spacing (float): The desired spacing between waypoints. + pos (HelperLatLon): The position of Sailbot. + """ + + path_spacing = calculate_interval_spacing(pos, global_path.waypoints) + + interpolated_path = _interpolate_path( + global_path=global_path, + interval_spacing=interval_spacing, + pos=pos, + path_spacing=path_spacing, + write=False, + ) + + assert isinstance(interpolated_path, Path) + + # Ensure proper spacing between waypoints + dists = calculate_interval_spacing(pos, interpolated_path.waypoints) + + assert max(dists) <= interval_spacing, "Interval spacing is not correct" + + +# ------------------------- TEST INTERVAL_SPACING ------------------------- +@pytest.mark.parametrize( + "pos,waypoints", + [ + ( + HelperLatLon(latitude=48.95, longitude=123.56), + [ + HelperLatLon(latitude=48.95, longitude=123.55), + ], + ), + ( + HelperLatLon(latitude=48.95, longitude=123.56), + [ + HelperLatLon(latitude=48.95, longitude=123.55), + HelperLatLon(latitude=85.95, longitude=13.56), + ], + ), + ( + HelperLatLon(latitude=48.95, longitude=123.56), + [ + HelperLatLon(latitude=48.95, longitude=123.55), + HelperLatLon(latitude=85.95, longitude=13.56), + HelperLatLon(latitude=85.00, longitude=13.00), + ], + ), + ], +) +def test_interval_spacing(pos: HelperLatLon, waypoints: list[HelperLatLon]): + """Test the greatest_interval method of MockGlobalPath. + + Args: + pos (HelperLatLon): The start position. + waypoints (list[HelperLatLon]): The waypoints of the global path. + """ + greatest_interval = max(calculate_interval_spacing(pos, waypoints)) + + if len(waypoints) > 1: + expected_interval = meters_to_km( + GEODESIC.inv( + lats1=waypoints[0].latitude, + lons1=waypoints[0].longitude, + lats2=waypoints[1].latitude, + lons2=waypoints[1].longitude, + )[2] + ) + else: + expected_interval = meters_to_km( + GEODESIC.inv( + lats1=pos.latitude, + lons1=pos.longitude, + lats2=waypoints[0].latitude, + lons2=waypoints[0].longitude, + )[2] + ) + + assert greatest_interval == pytest.approx( + expected_interval + ), "Greatest interval is not correct" + + +# ------------------------- TEST GENERATE_PATH ------------------------- +@pytest.mark.parametrize( + "pos,dest,interval_spacing", + [ + ( + HelperLatLon(latitude=48.95, longitude=123.56), + HelperLatLon(latitude=38.95, longitude=133.36), + 30.0, + ), + ( + HelperLatLon(latitude=-48.95, longitude=123.56), + HelperLatLon(latitude=38.95, longitude=-133.36), + 20.0, + ), + ( + HelperLatLon(latitude=48.95, longitude=123.56), + HelperLatLon(latitude=48.95, longitude=123.55), + 5.0, + ), + ], +) +def test_generate_path( + pos: HelperLatLon, + dest: HelperLatLon, + interval_spacing: float, +): + """Test the generate_path method of MockGlobalPath. + + Args: + dest (HelperLatLon): The destination of the global path. + pos (HelperLatLon): The position of Sailbot. + interval_spacing (float): The desired spacing between waypoints. + """ + global_path = generate_path( + dest=dest, + interval_spacing=interval_spacing, + pos=pos, + ) + + assert isinstance(global_path, Path) + + if isinstance(dest, list): + assert global_path.waypoints[-1].latitude == pytest.approx( + dest[-1].latitude + ), "final waypoint latitude is not correct" + assert global_path.waypoints[-1].longitude == pytest.approx( + dest[-1].longitude + ), "final waypoint longitude is not correct" + else: + assert global_path.waypoints[-1].latitude == pytest.approx( + expected=dest.latitude + ), "final waypoint latitude is not correct" + assert global_path.waypoints[-1].longitude == pytest.approx( + expected=dest.longitude + ), "final waypoint longitude is not correct" + + # Ensure proper spacing between waypoints + for i in range(1, len(global_path.waypoints)): + dist = GEODESIC.inv( + global_path.waypoints[i - 1].longitude, + global_path.waypoints[i - 1].latitude, + global_path.waypoints[i].longitude, + global_path.waypoints[i].latitude, + )[2] + dist *= 0.001 # convert to km + assert dist <= interval_spacing, "Interval spacing is not correct" + + +# ------------------------- TEST GET_MOST_RECENT_FILE ------------------------- +@pytest.mark.parametrize( + "file_path,global_path,tmstmp", + [ + ( + "/workspaces/sailbot_workspace/src/local_pathfinding/global_paths/test_file.csv", + Path(), + False, + ) + ], +) +def test_get_most_recent_file(file_path: str, global_path: Path, tmstmp: bool): + # create a file in the directory + write_to_file(file_path=file_path, global_path=global_path, tmstmp=tmstmp) + + assert ( + get_most_recent_file(directory_path=file_path[: -len(file_path.split("/")[-1])]) + == file_path + ), "Did not get most recent file" + + os.remove(file_path) + + +# ------------------------- TEST GET_PATH ------------------------- +@pytest.mark.parametrize( + "file_path", + [("/workspaces/sailbot_workspace/src/local_pathfinding/global_paths/mock_global_path.csv")], +) +def test_get_path(file_path: str): + """ " + Args: + file_path (str): The path to the global path csv file. + """ + global_path = get_path(file_path) + + assert isinstance(global_path, Path) + + # Check that the path is formatted correctly + for waypoint in global_path.waypoints: + assert isinstance(waypoint, HelperLatLon), "Waypoint is not a HelperLatLon" + assert isinstance(waypoint.latitude, float), "Waypoint latitude is not a float" + assert isinstance(waypoint.longitude, float), "Waypoint longitude is not a float" + + +# ------------------------- TEST GET_POS ------------------------- +@pytest.mark.parametrize( + "pos", [HelperLatLon(latitude=49.34175775635472, longitude=-123.35453636335373)] +) +def test_get_pos(pos: HelperLatLon): + """ + Args: + pos (HelperLatLon): The position of the Sailbot. + """ + + pos = get_pos() + assert pos is not None, "No position data received" + assert pos.latitude is not None, "No latitude" + assert pos.longitude is not None, "No longitude" + + +# ------------------------- TEST INTERPOLATE_PATH ------------------------- +@pytest.mark.parametrize( + "path,pos,interval_spacing", + [ + ( + Path( + waypoints=[ + HelperLatLon(latitude=48.95, longitude=123.56), + HelperLatLon(latitude=38.95, longitude=133.36), + HelperLatLon(latitude=28.95, longitude=143.36), + ] + ), + HelperLatLon(latitude=58.95, longitude=113.56), + 50.0, + ) + ], +) +def test_interpolate_path(path: Path, pos: HelperLatLon, interval_spacing: float): + """ + Args: + path (Path): The global path. + pos (HelperLatLon): The position of the Sailbot. + interval_spacing (float): The spacing between each waypoint. + """ + formatted_path = interpolate_path( + path=path, pos=pos, interval_spacing=interval_spacing, file_path="", write=False + ) + + assert isinstance(formatted_path, Path), "Formatted path is not a Path" + + # Check that the path is formatted correctly + for waypoint in formatted_path.waypoints: + assert isinstance(waypoint, HelperLatLon), "Waypoint is not a HelperLatLon" + assert isinstance(waypoint.latitude, float), "Waypoint latitude is not a float" + assert isinstance(waypoint.longitude, float), "Waypoint longitude is not a float" + + path_spacing = calculate_interval_spacing(pos, formatted_path.waypoints) + assert max(path_spacing) <= interval_spacing, "Path spacing is too large" + assert max(path_spacing) <= interval_spacing, "Path spacing is too large" + + +# ------------------------- TEST PATH_TO_DICT ------------------------- +@pytest.mark.parametrize( + "path,expected", + [ + ( + Path( + waypoints=[ + HelperLatLon(latitude=48.123446, longitude=123.123446), + HelperLatLon(latitude=38.123456, longitude=133.123456), + ] + ), + {0: "(48.1234, 123.1234)", 1: "(38.1235, 133.1235)"}, + ), + ], +) +def test_path_to_dict(path: Path, expected: dict[int, str]): + path_dict = path_to_dict(path) + assert path_dict == expected, "Did not correctly convert path to dictionary" + + +# ------------------------- TEST POST_PATH ------------------------- +@pytest.mark.parametrize( + "global_path", + [ + ( + Path( + waypoints=[ + HelperLatLon(latitude=48.95, longitude=123.56), + HelperLatLon(latitude=38.95, longitude=133.36), + HelperLatLon(latitude=28.95, longitude=143.36), + ] + ) + ) + ], +) +def test_post_path(global_path: Path): + """ + Args: + global_path (Path): The global path to post. + """ + + # Launch http server + server = post_server.run_server() + + assert post_path(global_path), "Failed to post global path" + + post_server.shutdown_server(httpd=server) + + +# ------------------------- TEST WRITE_TO_FILE ------------------------------ +@pytest.mark.parametrize( + "file_path", + [ + ("/workspaces/sailbot_workspace/src/local_pathfinding/anywhere_else/mock_global_path.csv"), + (""), + ("/workspaces/sailbot_workspace/src/local_pathfinding/ global_paths/mock_global_path.csv"), + ], +) +def test_write_to_file(file_path: str): + with pytest.raises(ValueError): + write_to_file(file_path=file_path, global_path=None) diff --git a/src/local_pathfinding/test/test_local_path.py b/src/local_pathfinding/test/test_local_path.py new file mode 100644 index 000000000..001c45f3c --- /dev/null +++ b/src/local_pathfinding/test/test_local_path.py @@ -0,0 +1,18 @@ +from custom_interfaces.msg import GPS, AISShips, Path, WindSensor +from rclpy.impl.rcutils_logger import RcutilsLogger + +import local_pathfinding.local_path as local_path + +PATH = local_path.LocalPath(parent_logger=RcutilsLogger()) + + +def test_LocalPath_update_if_needed(): + PATH.update_if_needed( + gps=GPS(), + ais_ships=AISShips(), + global_path=Path(), + filtered_wind_sensor=WindSensor(), + planner="bitstar", + ) + assert PATH.waypoints is not None, "waypoints is not initialized" + assert len(PATH.waypoints) > 1, "waypoints length <= 1" diff --git a/src/local_pathfinding/test/test_objectives.py b/src/local_pathfinding/test/test_objectives.py new file mode 100644 index 000000000..9289b650f --- /dev/null +++ b/src/local_pathfinding/test/test_objectives.py @@ -0,0 +1,306 @@ +import math + +import pytest +from custom_interfaces.msg import GPS, AISShips, HelperLatLon, Path, WindSensor +from rclpy.impl.rcutils_logger import RcutilsLogger + +import local_pathfinding.coord_systems as coord_systems +import local_pathfinding.objectives as objectives +import local_pathfinding.ompl_path as ompl_path +from local_pathfinding.local_path import LocalPathState + +# Upwind downwind cost multipliers +UPWIND_MULTIPLIER = 3000.0 +DOWNWIND_MULTIPLIER = 3000.0 + + +PATH = ompl_path.OMPLPath( + parent_logger=RcutilsLogger(), + max_runtime=1, + local_path_state=LocalPathState( + gps=GPS(), + ais_ships=AISShips(), + global_path=Path(), + filtered_wind_sensor=WindSensor(), + planner="bitstar", + ), +) + + +@pytest.mark.parametrize( + "method", + [ + objectives.DistanceMethod.EUCLIDEAN, + objectives.DistanceMethod.LATLON, + objectives.DistanceMethod.OMPL_PATH_LENGTH, + ], +) +def test_distance_objective(method: objectives.DistanceMethod): + distance_objective = objectives.DistanceObjective( + PATH._simple_setup.getSpaceInformation(), + method, + ) + assert distance_objective is not None + + +@pytest.mark.parametrize( + "cs1,cs2,expected", + [ + ((0, 0), (0, 0), 0), + ((0.5, 0.5), (0.1, 0.2), 0.5), + ], +) +def test_get_euclidean_path_length_objective(cs1: tuple, cs2: tuple, expected: float): + s1 = coord_systems.XY(*cs1) + s2 = coord_systems.XY(*cs2) + assert objectives.DistanceObjective.get_euclidean_path_length_objective(s1, s2) == expected + + +@pytest.mark.parametrize( + "rf, cs1,cs2", + [ + ((10.0, 10.0), (0.0, 0.0), (0.0, 0.0)), + ((13.206724, 29.829011), (13.208724, 29.827011), (13.216724, 29.839011)), + ((0.0, 0.0), (0.0, 0.1), (0.0, -0.1)), + ((0.0, 0.0), (0.1, 0.0), (-0.1, 0.0)), + ((0.0, 0.0), (0.1, 0.1), (-0.1, -0.1)), + ], +) +def test_get_latlon_path_length_objective(rf: tuple, cs1: tuple, cs2: tuple): + reference = HelperLatLon(latitude=rf[0], longitude=rf[1]) + s1 = HelperLatLon(latitude=cs1[0], longitude=cs1[1]) + s2 = HelperLatLon(latitude=cs2[0], longitude=cs2[1]) + ls1 = coord_systems.latlon_to_xy(reference, s1) + ls2 = coord_systems.latlon_to_xy(reference, s2) + _, _, distance_m = coord_systems.GEODESIC.inv( + lats1=s1.latitude, + lons1=s1.longitude, + lats2=s2.latitude, + lons2=s2.longitude, + ) + + assert objectives.DistanceObjective.get_latlon_path_length_objective( + ls1, + ls2, + reference, + ) == pytest.approx(distance_m) + + +@pytest.mark.parametrize( + "method", + [ + objectives.MinimumTurningMethod.GOAL_HEADING, + objectives.MinimumTurningMethod.GOAL_PATH, + objectives.MinimumTurningMethod.HEADING_PATH, + ], +) +def test_minimum_turning_objective(method: objectives.MinimumTurningMethod): + minimum_turning_objective = objectives.MinimumTurningObjective( + PATH._simple_setup.getSpaceInformation(), + PATH._simple_setup, + PATH.state.heading_direction, + method, + ) + assert minimum_turning_objective is not None + + +@pytest.mark.parametrize( + "cs1,sf,heading_degrees,expected", + [ + ((0, 0), (0, 0), 0, 0), + ((-1, -1), (0.1, 0.2), 45, 2.490), + ], +) +def test_goal_heading_turn_cost(cs1: tuple, sf: tuple, heading_degrees: float, expected: float): + s1 = coord_systems.XY(*cs1) + goal = coord_systems.XY(*sf) + heading = math.radians(heading_degrees) + assert objectives.MinimumTurningObjective.goal_heading_turn_cost( + s1, goal, heading + ) == pytest.approx(expected, abs=1e-3) + + +@pytest.mark.parametrize( + "cs1,cs2,sf,expected", + [ + ((0, 0), (0, 0), (0, 0), 0), + ((-1, -1), (2, 1), (0.1, 0.2), 13.799), + ], +) +def test_goal_path_turn_cost(cs1: tuple, cs2: tuple, sf: tuple, expected: float): + s1 = coord_systems.XY(*cs1) + s2 = coord_systems.XY(*cs2) + goal = coord_systems.XY(*sf) + + assert objectives.MinimumTurningObjective.goal_path_turn_cost(s1, s2, goal) == pytest.approx( + expected, abs=1e-3 + ) + + +@pytest.mark.parametrize( + "cs1,cs2,heading_degrees,expected", + [ + ((0, 0), (0, 0), 0.0, 0), + ((-1, -1), (2, 1), 45.0, 11.310), + ], +) +def test_heading_path_turn_cost(cs1: tuple, cs2: tuple, heading_degrees: float, expected: float): + s1 = coord_systems.XY(*cs1) + s2 = coord_systems.XY(*cs2) + heading = math.radians(heading_degrees) + + assert objectives.MinimumTurningObjective.heading_path_turn_cost( + s1, s2, heading + ) == pytest.approx(expected, abs=1e-3) + + +@pytest.mark.parametrize( + "cs1,cs2,wind_direction_deg,expected", + [ + ((0, 0), (0, 0), 0.0, 0 * UPWIND_MULTIPLIER), + ((-1, -1), (2, 1), 45.0, 3.605551275 * UPWIND_MULTIPLIER), + ], +) +def test_wind_direction_cost(cs1: tuple, cs2: tuple, wind_direction_deg: float, expected: float): + s1 = coord_systems.XY(*cs1) + s2 = coord_systems.XY(*cs2) + wind_direction = math.radians(wind_direction_deg) + assert objectives.WindObjective.wind_direction_cost(s1, s2, wind_direction) == pytest.approx( + expected, abs=1e-3 + ) + + +@pytest.mark.parametrize( + "wind_direction_deg,heading_deg,expected", + [ + (0, 0.0, True), + (0.0, 45.0, False), + ], +) +def test_is_upwind(wind_direction_deg: float, heading_deg: float, expected: float): + wind_direction = math.radians(wind_direction_deg) + heading = math.radians(heading_deg) + + assert objectives.WindObjective.is_upwind(wind_direction, heading) == expected + + +@pytest.mark.parametrize( + "wind_direction_deg,heading_deg,expected", + [ + (0.0, 0.0, False), + (25.0, 46.0, False), + (0, 180, True), + (225, 45, True), + ], +) +def test_is_downwind(wind_direction_deg: float, heading_deg: float, expected: float): + wind_direction = math.radians(wind_direction_deg) + heading = math.radians(heading_deg) + + assert objectives.WindObjective.is_downwind(wind_direction, heading) == expected + + +@pytest.mark.parametrize( + "afir,amid,asec,expected", + [ + (0, 1, 2, 1), + (0, 20, 360, 0), + (-20, 10, 40, 1), + (0, 30, 60, 1), + (-170, -130, -90, 1), + (-170, -130, 100, 0), + (400, 410, 420, 1), + (400, 420, 410, 0), + (370, 0, -370, 1), + (370, 15, -370, 0), + (-90, 270, 450, 0), + ], +) +def test_angle_between(afir: float, amid: float, asec: float, expected: float): + assert ( + objectives.WindObjective.is_angle_between( + math.radians(afir), math.radians(amid), math.radians(asec) + ) + == expected + ) + + +@pytest.mark.parametrize( + "method", + [ + objectives.SpeedObjectiveMethod.SAILBOT_TIME, + objectives.SpeedObjectiveMethod.SAILBOT_PIECEWISE, + objectives.SpeedObjectiveMethod.SAILBOT_CONTINUOUS, + ], +) +def test_speed_objective(method: objectives.SpeedObjectiveMethod): + speed_objective = objectives.SpeedObjective( + PATH._simple_setup.getSpaceInformation(), + PATH.state.heading_direction, + PATH.state.wind_direction, + PATH.state.wind_speed, + method, + ) + assert speed_objective is not None + + +@pytest.mark.parametrize( + "heading,wind_direction,wind_speed,expected", + [ + # Corners of the table + (0, 0, 0, 0), + (-90, 90, 37.0, 18.5), + (0, 180, 0, 0), + (0, 0, 37.0, 0), + # Edges of table + (-48, 22, 0, 0), + (-22, 140, 0, 0), + (63, 63, 9.3, 0), + (-81, -81, 32.3, 0), + # Other edge cases + (60, -120, 10.6, 3.704347826), + (170, -155, 37, 6.833333333), + (-50, -152.7, 27.8, 15.844222222), + (-170, 160, 14.4, 1.231521739), + (0, 45, 18.5, 3.7), + # General cases + (-20, 40, 12.0, 2.905434783), + (12.9, -1, 5.3, 0), + ], +) +def test_get_sailbot_speed( + heading: float, wind_direction: float, wind_speed: float, expected: float +): + assert objectives.SpeedObjective.get_sailbot_speed( + heading, wind_direction, wind_speed + ) == pytest.approx(expected, abs=1e-7) + + +@pytest.mark.parametrize( + "speed,expected", + [ + (0.0, 5), + (8, 10), + (12.5, 20), + (17.0, 50), + (35, 10000), + ], +) +def test_piecewise_cost(speed: float, expected: int): + assert objectives.SpeedObjective.get_piecewise_cost(speed) == expected + + +@pytest.mark.parametrize( + "speed,expected", + [ + (0.0, 10000), + (25.0, 10000), + (30, 2.2013016167), + (40, 1.55146222424), + (10, 0.551462224238), + ], +) +def test_continuous_cost(speed: float, expected: int): + assert objectives.SpeedObjective.get_continuous_cost(speed) == pytest.approx( + expected, abs=1e-3 + ) diff --git a/src/local_pathfinding/test/test_obstacles.py b/src/local_pathfinding/test/test_obstacles.py new file mode 100644 index 000000000..67a6b8170 --- /dev/null +++ b/src/local_pathfinding/test/test_obstacles.py @@ -0,0 +1,476 @@ +import numpy as np +import pytest +from custom_interfaces.msg import ( + HelperAISShip, + HelperDimension, + HelperHeading, + HelperLatLon, + HelperROT, + HelperSpeed, +) +from shapely.geometry import Point, Polygon + +from local_pathfinding.coord_systems import XY, latlon_to_xy, meters_to_km +from local_pathfinding.obstacles import COLLISION_ZONE_SAFETY_BUFFER, Boat, Obstacle + + +# Test calculate projected distance +# Boat and Sailbot in same location +@pytest.mark.parametrize( + "reference_point,sailbot_position,ais_ship,sailbot_speed", + [ + ( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.957, longitude=-136.262), + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.957, longitude=-136.262), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + ) + ], +) +def test_calculate_projected_distance( + reference_point: HelperLatLon, + sailbot_position: HelperLatLon, + ais_ship: HelperAISShip, + sailbot_speed: float, +): + boat1 = Boat(reference_point, sailbot_position, sailbot_speed, ais_ship) + + assert boat1.calculate_projected_distance() == pytest.approx( + 0.0 + ), "incorrect projected distance" + + +# Test collision zone is created successfully +@pytest.mark.parametrize( + "reference_point,sailbot_position,ais_ship,sailbot_speed", + [ + ( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + ) + ], +) +def test_create_collision_zone( + reference_point: HelperLatLon, + sailbot_position: HelperLatLon, + ais_ship: HelperAISShip, + sailbot_speed: float, +): + boat1 = Boat(reference_point, sailbot_position, sailbot_speed, ais_ship) + boat1.update_boat_collision_zone() + + assert isinstance(boat1.collision_zone, Polygon) + if boat1.collision_zone is not None: + assert boat1.collision_zone.exterior.coords is not None + + +# Test collision zone is positioned correctly +# ais_ship is positioned at the reference point +@pytest.mark.parametrize( + "reference_point,sailbot_position,ais_ship,sailbot_speed", + [ + ( + HelperLatLon(latitude=52.0, longitude=-136.0), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=52.0, longitude=-136.0), + cog=HelperHeading(heading=0.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + ) + ], +) +def test_position_collision_zone( + reference_point: HelperLatLon, + sailbot_position: HelperLatLon, + ais_ship: HelperAISShip, + sailbot_speed: float, +): + boat1 = Boat(reference_point, sailbot_position, sailbot_speed, ais_ship) + + if boat1.collision_zone is not None: + unbuffered = boat1.collision_zone.buffer(-COLLISION_ZONE_SAFETY_BUFFER, join_style=2) + x, y = np.array(unbuffered.exterior.coords.xy) + x = np.array(x) + y = np.array(y) + assert (x[0] + meters_to_km(boat1.ais_ship.width.dimension) / 2) == pytest.approx(0) + assert (y[0] + meters_to_km(boat1.ais_ship.length.dimension) / 2) == pytest.approx(0) + + +# Test create collision zone raises error when id of passed ais_ship does not match self's id +@pytest.mark.parametrize( + "reference_point,sailbot_position,ais_ship_1,ais_ship_2,sailbot_speed", + [ + ( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + HelperAISShip( + id=2, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + ) + ], +) +def test_create_collision_zone_id_mismatch( + reference_point: HelperLatLon, + sailbot_position: HelperLatLon, + ais_ship_1: HelperAISShip, + ais_ship_2: HelperAISShip, + sailbot_speed: float, +): + boat1 = Boat(reference_point, sailbot_position, sailbot_speed, ais_ship_1) + + with pytest.raises(ValueError): + boat1.update_boat_collision_zone(ais_ship_2) + + +# Test is_valid +@pytest.mark.parametrize( + "reference_point,sailbot_position,ais_ship,sailbot_speed,invalid_point,valid_point", + [ + ( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + HelperAISShip( + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=0.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + latlon_to_xy( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=52.174842845359755, longitude=-137.10372451905042), + ), + latlon_to_xy( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=49.30499213908291, longitude=-123.31330140816111), + ), + ) + ], +) +def test_is_valid( + reference_point: HelperLatLon, + sailbot_position: HelperLatLon, + ais_ship: HelperAISShip, + sailbot_speed: float, + invalid_point: XY, + valid_point: XY, +): + boat1 = Boat(reference_point, sailbot_position, sailbot_speed, ais_ship) + assert not boat1.is_valid(invalid_point) + assert boat1.is_valid(valid_point) + + +# Test is_valid raises error when collision zone has not been set +@pytest.mark.parametrize( + "reference_point,sailbot_position,sailbot_speed,invalid_point,valid_point", + [ + ( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + 15.0, + latlon_to_xy( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=52.174842845359755, longitude=-137.10372451905042), + ), + latlon_to_xy( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=49.30499213908291, longitude=-123.31330140816111), + ), + ) + ], +) +def test_is_valid_no_collision_zone( + reference_point: HelperLatLon, + sailbot_position: HelperLatLon, + sailbot_speed: float, + invalid_point: XY, + valid_point: XY, +): + obstacle = Obstacle(reference_point, sailbot_position, sailbot_speed) + with pytest.raises(ValueError): + obstacle.is_valid(invalid_point) + with pytest.raises(ValueError): + obstacle.is_valid(valid_point) + + +# Test updating Sailbot data +@pytest.mark.parametrize( + "ref_point,sailbot_position_1,sailbot_speed_1,sailbot_position_2,sailbot_speed_2,ais_ship", + [ + ( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.9, longitude=-136.2), + 15.0, + HelperLatLon(latitude=52.9, longitude=-137.2), + 20.0, + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + ) + ], +) +def test_update_sailbot_data( + ref_point: HelperLatLon, + sailbot_position_1: HelperLatLon, + sailbot_speed_1: float, + sailbot_position_2: HelperLatLon, + sailbot_speed_2: float, + ais_ship: HelperAISShip, +): + boat1 = Boat(ref_point, sailbot_position_1, sailbot_speed_1, ais_ship) + boat1.update_sailbot_data(sailbot_position_2, sailbot_speed_2) + + assert boat1.sailbot_position == pytest.approx(latlon_to_xy(ref_point, sailbot_position_2)) + assert boat1.sailbot_speed == pytest.approx(sailbot_speed_2) + + +# Test update reference point +@pytest.mark.parametrize( + "reference_point_1,reference_point_2,sailbot_position,ais_ship,sailbot_speed", + [ + ( + HelperLatLon(latitude=52.2, longitude=-136.9), + HelperLatLon(latitude=51.0, longitude=-136.0), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + ), + ( + HelperLatLon(latitude=50.06442134644842, longitude=-130.7725487868677), + HelperLatLon(latitude=49.88670956993386, longitude=-130.37061359404225), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=30.0), + sog=HelperSpeed(speed=20.0), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ), + 15.0, + ), + ], +) +def test_update_reference_point( + reference_point_1: HelperLatLon, + reference_point_2: HelperLatLon, + sailbot_position: HelperLatLon, + ais_ship: HelperAISShip, + sailbot_speed: float, +): + boat1 = Boat(reference_point_1, sailbot_position, sailbot_speed, ais_ship) + if isinstance(boat1.collision_zone, Polygon): + point1 = Point( + boat1.collision_zone.exterior.coords.xy[0][0], + boat1.collision_zone.exterior.coords.xy[1][0], + ) + + assert boat1.reference == reference_point_1 + assert boat1.sailbot_position == pytest.approx( + latlon_to_xy(reference_point_1, sailbot_position) + ) + # Change the reference point + boat1.update_reference_point(reference_point_2) + if isinstance(boat1.collision_zone, Polygon): + point2 = Point( + boat1.collision_zone.exterior.coords.xy[0][0], + boat1.collision_zone.exterior.coords.xy[1][0], + ) + + assert boat1.reference == reference_point_2 + assert boat1.sailbot_position_latlon == sailbot_position + assert boat1.sailbot_position == pytest.approx( + latlon_to_xy(reference_point_2, sailbot_position) + ) + + # Calculate the expected displacement based on the old and new reference point + x_displacement, y_displacement = latlon_to_xy(reference_point_2, reference_point_1) + displacement = np.sqrt(x_displacement**2 + y_displacement**2) + # calculate how far the collision zone was actually translated on reference point update + translation = point1.distance(point2) + + # There is some error in the latlon_to_xy conversion but the results are close + assert translation == pytest.approx(displacement, rel=0.1), "incorrect translation" + + +if __name__ == "__main__": + """VISUAL TESTS + + TODO: verify calculate_projected_distance via numerical/approximation method and show it + converges to the analytical solution given by calculate_projected_distance. Maybe turn into + an animation. + + The collision zone length can be verified visually, using the plotly chart below. + + The values for the measured and calculated/expected length of the collision zones + match, shown in the top right. + + The invalid state point is within the collision zone and the valid state point is outside. + Validity for the same points is checked above in: test_is_valid + + Increasing the cog of the ais_ship, starting from zero, corresponds to a clockwise rotation, + starting from true north (the y axis) as expected. + """ + import plotly.graph_objects as go + from numpy import ndarray + + # Sample AIS SHIP message + ais_ship = HelperAISShip( + id=1, + lat_lon=HelperLatLon(latitude=51.97917631092298, longitude=-137.1106454702385), + cog=HelperHeading(heading=0.0), + sog=HelperSpeed(speed=18.52), + width=HelperDimension(dimension=20.0), + length=HelperDimension(dimension=100.0), + rot=HelperROT(rot=0), + ) + + # Create a boat object + boat1 = Boat( + HelperLatLon(latitude=52.268119490007756, longitude=-136.9133983613776), + HelperLatLon(latitude=51.95785651405779, longitude=-136.26282894969611), + 30.0, + ais_ship, + ) + + # Choose some states for visual inspection + valid_state = HelperLatLon(latitude=50.42973337261916, longitude=-134.12018940923838) + invalid_state = HelperLatLon(latitude=52.174842845359755, longitude=-137.10372451905042) + + # Extract coordinates for sailbot + sailbot_x, sailbot_y = boat1.sailbot_position + sailbot = go.Scatter(x=[sailbot_x], y=[sailbot_y], mode="markers", name="Sailbot Position") + + fig1 = go.Figure(sailbot) + + # Extract coordinates for valid and invalid states + valid_state_x, valid_state_y = latlon_to_xy(boat1.reference, valid_state) + valid_state = go.Scatter( + x=[valid_state_x], y=[valid_state_y], mode="markers", name="Valid State" + ) + + fig1.add_trace(valid_state) + + invalid_state_x, invalid_state_y = latlon_to_xy(boat1.reference, invalid_state) + invalid_state = go.Scatter( + x=[invalid_state_x], y=[invalid_state_y], mode="markers", name="Invalid State" + ) + + fig1.add_trace(invalid_state) + + # Extract exterior coordinates for boat1's collision cone + if boat1.collision_zone is not None: + boat_x, boat_y = np.array(boat1.collision_zone.exterior.coords.xy) + boat_x = np.array(boat_x) + boat_y = np.array(boat_y) + boat = go.Scatter(x=boat_x, y=boat_y, fill="toself", name="Boat Collision Cone") + fig1.add_trace(boat) + + # Manually calculate the length of the collision zone based on: + # - the boat's projected distance + # - the boat's length + # - the safety buffer + collision_zone_length = round( + ( + boat1.calculate_projected_distance() + + 2 * COLLISION_ZONE_SAFETY_BUFFER + + meters_to_km(boat1.ais_ship.length.dimension) + ), + 4, + ) + + fig1.add_annotation( + text="Calculated Length of Collision Zone : " + str(collision_zone_length) + " km", + align="center", + showarrow=False, + xref="paper", + yref="paper", + x=1, + y=1, + bordercolor="black", + borderwidth=1, + ) + + # Measure the length of the collision zone based on the points of the polygon + x: ndarray + y: ndarray + + if boat1.collision_zone is not None: + x, y = boat1.collision_zone.exterior.coords.xy + + mid_1 = Point((x[1] + x[2]) / 2, (y[1] + y[2]) / 2) + mid_2 = Point((x[0] + x[3]) / 2, (y[0] + y[3]) / 2) + + length = round(mid_1.distance(mid_2), 4) + + fig1.add_annotation( + text="Measured Length of Collision Zone : " + str(length) + " km", + align="center", + showarrow=False, + xref="paper", + yref="paper", + x=0.7, + y=1, + bordercolor="black", + borderwidth=1, + ) + + fig1.update_layout(yaxis_range=[-200, 200], xaxis_range=[-200, 750]) + fig1.show() diff --git a/src/local_pathfinding/test/test_ompl_path.py b/src/local_pathfinding/test/test_ompl_path.py new file mode 100644 index 000000000..e7662566d --- /dev/null +++ b/src/local_pathfinding/test/test_ompl_path.py @@ -0,0 +1,80 @@ +import pytest +from custom_interfaces.msg import GPS, AISShips, Path, WindSensor +from ompl import base as ob +from rclpy.impl.rcutils_logger import RcutilsLogger + +import local_pathfinding.coord_systems as cs +import local_pathfinding.ompl_path as ompl_path +from local_pathfinding.local_path import LocalPathState + +PATH = ompl_path.OMPLPath( + parent_logger=RcutilsLogger(), + max_runtime=1, + local_path_state=LocalPathState( + gps=GPS(), + ais_ships=AISShips(), + global_path=Path(), + filtered_wind_sensor=WindSensor(), + planner="bitstar", + ), +) + + +def test_OMPLPathState(): + state = ompl_path.OMPLPathState(local_path_state=None, logger=RcutilsLogger()) + assert state.state_domain == (-1, 1), "incorrect value for attribute state_domain" + assert state.state_range == (-1, 1), "incorrect value for attribute start_state" + assert state.start_state == pytest.approx( + (0.5, 0.4) + ), "incorrect value for attribute start_state" + assert state.goal_state == pytest.approx( + (0.5, -0.4) + ), "incorrect value for attribute goal_state" + + +def test_OMPLPath___init__(): + assert PATH.solved + + +def test_OMPLPath_get_cost(): + with pytest.raises(NotImplementedError): + PATH.get_cost() + + +def test_OMPLPath_get_waypoint(): + waypoints = PATH.get_waypoints() + + waypoint_XY = cs.XY(*PATH.state.start_state) + start_state_latlon = cs.xy_to_latlon(PATH.state.reference_latlon, waypoint_XY) + + test_start = waypoints[0] + test_goal = waypoints[-1] + + assert (test_start.latitude, test_start.longitude) == pytest.approx( + (start_state_latlon.latitude, start_state_latlon.longitude), abs=1e-2 + ), "first waypoint should be start state" + assert (test_goal.latitude, test_goal.longitude) == pytest.approx( + (PATH.state.reference_latlon.latitude, PATH.state.reference_latlon.longitude), abs=1e-2 + ), "last waypoint should be goal state" + + +def test_OMPLPath_update_objectives(): + with pytest.raises(NotImplementedError): + PATH.update_objectives() + + +@pytest.mark.parametrize( + "x,y,is_valid", + [ + (0.5, 0.5, True), + (0.6, 0.6, False), + ], +) +def test_is_state_valid(x: float, y: float, is_valid: bool): + state = ob.State(PATH._simple_setup.getStateSpace()) + state().setXY(x, y) + + if is_valid: + assert ompl_path.is_state_valid(state()), "state should be valid" + else: + assert not ompl_path.is_state_valid(state()), "state should not be valid" diff --git a/src/network_systems/.gitignore b/src/network_systems/.gitignore new file mode 100644 index 000000000..ec6f8109d --- /dev/null +++ b/src/network_systems/.gitignore @@ -0,0 +1,6 @@ +# autogenerated files +/lib/cmn_hdrs/ros_info.h +*.pyc + +# PlantUML diagram export directory +/diagrams/out/ diff --git a/src/network_systems/CMakeLists.txt b/src/network_systems/CMakeLists.txt new file mode 100755 index 000000000..b2f565b05 --- /dev/null +++ b/src/network_systems/CMakeLists.txt @@ -0,0 +1,77 @@ +cmake_minimum_required(VERSION 3.10) +project(network_systems) +include(functions.cmake) + +# Options and constants +option(STATIC_ANALYSIS "Enable clang-tidy checks" ON) +option(UNIT_TEST "Enable unit tests" ON) +set(CLANG_VERSION "14") + +# Configure the compiler +set(CMAKE_CXX_COMPILER "/usr/bin/clang++-${CLANG_VERSION}") +if(STATIC_ANALYSIS) + set(CMAKE_CXX_CLANG_TIDY "clang-tidy-${CLANG_VERSION}") +endif() +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Configure build flags +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(coverage_flags "-fprofile-arcs -ftest-coverage -fprofile-instr-generate -fcoverage-mapping") + set(CMAKE_CXX_FLAGS + "${CMAKE_CXX_FLAGS_DEBUG}" + # ${coverage_flags} Needs to be implemented + ) +elseif(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS_RELWITHDEBINFO}") +endif() +# Enable all warnings as errors except nested-anon-types, as it allows us to declare an unnamed struct within an +# anonymous union, which is allowed in the C++ standard. This appears to be a warning specific to clang++ with +# -Wpedantic, and does not appear with g++ or MSVC +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20 -Wall -Wextra -Wpedantic -Werror -Wno-nested-anon-types -pthread") +message(WARNING "Building Network Systems with build type '${CMAKE_BUILD_TYPE}' " + "and flags: '${CMAKE_CXX_FLAGS}'") + +# ROS dependencies +set(ROS_DEPS + rclcpp + std_msgs + custom_interfaces +) +find_package(ament_cmake REQUIRED) +foreach(dep IN LISTS ROS_DEPS) + find_package(${dep} REQUIRED) +endforeach() + + +# Boost +find_package(Boost 1.74.0 COMPONENTS REQUIRED + program_options + serialization + system + thread +) + +# MongoDB +find_package(mongocxx REQUIRED) +find_package(bsoncxx REQUIRED) + +# Protobuf +find_package(Protobuf REQUIRED) +set(PROTOBUF_LINK_LIBS + ${Protobuf_LIBRARIES} + protofiles +) + +# Googletest (installed by sailbot_workspace Docker config) +if(UNIT_TEST) + find_package(GTest CONFIG REQUIRED) + set(GTEST_LINK_LIBS gtest gtest_main) +endif() + +# Add src directories +add_subdirectory(lib) +add_subdirectory(projects) + +# Install launch files +install(DIRECTORY launch DESTINATION share/network_systems) + +ament_package() diff --git a/src/network_systems/LICENSE b/src/network_systems/LICENSE new file mode 100644 index 000000000..30e8e2ece --- /dev/null +++ b/src/network_systems/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/network_systems/README.md b/src/network_systems/README.md new file mode 100755 index 000000000..eb0bb74e0 --- /dev/null +++ b/src/network_systems/README.md @@ -0,0 +1,93 @@ +# Network Systems + +[![Tests](https://github.com/UBCSailbot/network_systems/actions/workflows/tests.yml/badge.svg)](https://github.com/UBCSailbot/network_systems/actions/workflows/tests.yml) + +This repository contains the source code for all of UBC Sailbot's Network Systems programs. It is made to work as part +of [Sailbot Workspace](https://github.com/UBCSailbot/sailbot_workspace), and is **_not_** meant to be built as an +independent project. + +## Setup + +For comprehensive setup instructions, follow our [setup guide](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/setup/). + +## Building + +**Option A**: With sailbot_workspace open, invoke the VSCode `build` or `debug` task. + +**Option B**: Run `/workspaces/sailbot_workspace/build.sh` + +## Running + +### ROS Launch + +[Instructions found here.](https://ubcsailbot.github.io/sailbot_workspace/main/current/sailbot_workspace/launch_files/) + +For example: + +```bash +ros2 launch network_systems main_launch.py +``` + +This is the best option if multiple modules need to be run at once. Launch configurations are found under the +[config](config/) folder. These configurations define which modules to enable/disable and what parameters to use. + +### ROS Run + +If you just want to run a single module, then this is a direct and easy way to do it. + +For example: + +```bash +ros2 run network_systems example --ros-args -p enabled:=true +``` + +### Binary + +Not recommended as you cannot pass ROS parameters, so modules may not work by default. Binaries for each module found +under [projects](projects/) can be found under +`/workspaces/sailbot_workspace/build/network_systems/projects/{module_name}/{module_name}`. + +For example: + +```bash +/workspaces/sailbot_workspace/build/network_systems/projects/example/example +``` + +## Testing + +Unit tests specific to Network Systems is done using [GoogleTest](https://github.com/google/googletest). Unit tests +are defined per module. For example, under [projects/example/test/](projects/example/test/test_cached_fib.cpp). + +### Run All Tests + +**Option A**: With sailbot_workspace open, invoke the VSCode `test` task. + +**Option B**: Under the sailbot_workspace directory, run `/workspaces/sailbot_workspace/test.sh` + +Both options will run all of UBC Sailbot's tests, including those from other projects. More often than not, this is +unnecessary. + +### Run and Debug Specific Tests + +This is the preferred way to run and debug tests. When you open a test source file like +[the example's](projects/example/test/test_cached_fib.cpp), there will be green arrows next to each `TEST_F` macro. +Clicking a double green arrow runs a test suite, while clicking single green arrow runs one unit test. Right clicking +either arrow will open a prompt with a debug test option. When running a test via the debug option, we can set +breakpoints and step through our code line by line to resolve issues. + +This convenient testing frontend is thank's to the +[TestMate extension](https://marketplace.visualstudio.com/items?itemName=matepek.vscode-catch2-test-adapter). + +**Warning**: Large failing tests can crash VSCode. If this happens, either lower the size of the tests (ex. reduce +the number of iterations) or [run the test binary directly](#run-test-binaries). + +### Run Test Binaries + +Test binaries for each module found under projects can be found under +`/workspaces/sailbot_workspace/build/network_systems/projects/{module_name}/test_{module_name}`. + +For example: + +```bash +/workspaces/sailbot_workspace/build/network_systems/projects/example/test_example +``` diff --git a/src/network_systems/config/README.md b/src/network_systems/config/README.md new file mode 100644 index 000000000..848df7b3b --- /dev/null +++ b/src/network_systems/config/README.md @@ -0,0 +1,48 @@ +# Launch Configs + +`.yaml` files defining ROS node parameters for different network_systems modules. Each `.yaml` file should have a short +description at the top describing its purpose. + +## How to Run + +To run with pure default defined in the code, run: + +```shell +ros2 launch network_systems main_launch.py +``` + +To run with config files in this folder: + +```shell +ros2 launch network_systems main_launch.py config:= +``` + +For example: + +```shell +ros2 launch network_systems main_launch.py config:=default_prod_en.yaml +``` + +launches network_systems with the parameters specified in `default_prod_en.yaml`. + +```shell +ros2 launch network_systems main_launch.py config:=default_prod_en.yaml,example/example_en.yaml +``` + +launches network_systems with the parameters specified in `default_prod_en.yaml` *and* `example/example_en.yaml`. Since +`example_en.yaml` is specified after `default_prod_en.yaml`, it overrides any duplicate parameters. + +**NOTE**: Instead of defining a `mode` parameter for each node, a global ROS launch argument is used. + +```shell +ros2 launch network_systems main_launch.py config:=<...> mode:=production +ros2 launch network_systems main_launch.py config:=<...> mode:=development +``` + +## Sub-folders + +Each sub-folder contains configs specific to each module found under [network_systems/projects](../projects/). Aside +from the trivial `example/` config, there should be a `_template.yaml` file with the available parameters. +However, this template file is not automatically synchronized with parameter declaration in the code, which are what +actually matter. Hence, the most up-to-date parameter declarations are found under +`network_systems/projects//src/_ros_intf.cpp`. diff --git a/src/network_systems/config/all_disable.yaml b/src/network_systems/config/all_disable.yaml new file mode 100644 index 000000000..4c65913e8 --- /dev/null +++ b/src/network_systems/config/all_disable.yaml @@ -0,0 +1,21 @@ +# Configuration that disables everything +# Allows us to overload the config:=... arguments such that we only launch certain nodes (useful for integration testing) +can_transceiver_node: + ros__parameters: + enabled: false + +cached_fib_subscriber: + ros__parameters: + enabled: false + +local_transceiver_node: + ros__parameters: + enabled: false + +mock_ais_node: + ros__parameters: + enabled: false + +remote_transceiver_node: + ros__parameters: + enabled: false diff --git a/src/network_systems/config/can_transceiver/can_transceiver_template.yaml b/src/network_systems/config/can_transceiver/can_transceiver_template.yaml new file mode 100644 index 000000000..9919e4446 --- /dev/null +++ b/src/network_systems/config/can_transceiver/can_transceiver_template.yaml @@ -0,0 +1,4 @@ +# Template for the can_transceiver module +can_transceiver_node: + ros__parameters: + enabled: true diff --git a/src/network_systems/config/default_dev_en.yaml b/src/network_systems/config/default_dev_en.yaml new file mode 100644 index 000000000..675c4ec30 --- /dev/null +++ b/src/network_systems/config/default_dev_en.yaml @@ -0,0 +1,20 @@ +# Configuration that enables the mandatory deployment modules and uses their defaults +can_transceiver_node: + ros__parameters: + enabled: true + +cached_fib_subscriber: + ros__parameters: + enabled: false + +local_transceiver_node: + ros__parameters: + enabled: true + +mock_ais_node: + ros__parameters: + enabled: true + +remote_transceiver_node: + ros__parameters: + enabled: true diff --git a/src/network_systems/config/default_prod_en.yaml b/src/network_systems/config/default_prod_en.yaml new file mode 100644 index 000000000..d698d0e7d --- /dev/null +++ b/src/network_systems/config/default_prod_en.yaml @@ -0,0 +1,20 @@ +# Configuration that enables the mandatory production modules and uses their defaults +can_transceiver_node: + ros__parameters: + enabled: true + +cached_fib_node: + ros__parameters: + enabled: false + +local_transceiver_node: + ros__parameters: + enabled: true + +mock_ais_node: + ros__parameters: + enabled: false + +remote_transceiver_node: + ros__parameters: + enabled: true diff --git a/src/network_systems/config/example/example_en.yaml b/src/network_systems/config/example/example_en.yaml new file mode 100644 index 000000000..91f0e758e --- /dev/null +++ b/src/network_systems/config/example/example_en.yaml @@ -0,0 +1,4 @@ +# Configuration that enables the cached_fib example module +cached_fib_node: + ros__parameters: + enabled: true diff --git a/src/network_systems/config/mock_ais/mock_ais_en_default.yaml b/src/network_systems/config/mock_ais/mock_ais_en_default.yaml new file mode 100644 index 000000000..c34552442 --- /dev/null +++ b/src/network_systems/config/mock_ais/mock_ais_en_default.yaml @@ -0,0 +1,4 @@ +# Enable Mock AIS with default settings +mock_ais_node: + ros__parameters: + enabled: true diff --git a/src/network_systems/config/mock_ais/mock_ais_template.yaml b/src/network_systems/config/mock_ais/mock_ais_template.yaml new file mode 100644 index 000000000..aa9b839ac --- /dev/null +++ b/src/network_systems/config/mock_ais/mock_ais_template.yaml @@ -0,0 +1,10 @@ +# Template for the Mock AIS module +mock_ais_node: + ros__parameters: + enabled: true + # The following parameters are optional. Defaults are set in mock_ais_ros_intf.cpp + publish_rate_ms: # Integer: How frequently the Mock AIS publishes data in milliseconds (ex. 500) + seed: # Integer: Random seed used for random data generation + num_sim_ships: # Integer: Total number of AIS ships to simulate (does not include Polaris) + polaris_start_pos: # Integer Array (size 2): Initial latitude and longitude of Polaris for simulation + # (ex. [49.283, -123.652]) diff --git a/src/network_systems/config/remote_transceiver/remote_transceiver_template.yaml b/src/network_systems/config/remote_transceiver/remote_transceiver_template.yaml new file mode 100644 index 000000000..cc6cbe1f3 --- /dev/null +++ b/src/network_systems/config/remote_transceiver/remote_transceiver_template.yaml @@ -0,0 +1,9 @@ +# Template for the remote_transceiver module +remote_transceiver_node: + ros__parameters: + enabled: true + # The following parameters are optional. Defaults are set in remote_transceiver_ros_intf.cpp + db_name: # String: Name of mongodb database + host: # String: IP or URL that the Remote Transceiver will use for the server (ex. 127.0.0.1) + port: # Integer: Port that the Remote Transceiver will use for the server (ex. 8081) + num_threads: # Integer: Number of concurrent threads available to accept simulataneous HTTP requests diff --git a/src/network_systems/functions.cmake b/src/network_systems/functions.cmake new file mode 100644 index 000000000..92d040848 --- /dev/null +++ b/src/network_systems/functions.cmake @@ -0,0 +1,54 @@ +# Create module library +function(make_lib module srcs link_libs inc_dirs compile_defs) + add_library(${module} ${srcs}) + ament_target_dependencies(${module} PUBLIC ${ROS_DEPS}) + target_compile_definitions(${module} PUBLIC ${compile_defs}) + target_link_libraries(${module} PUBLIC ${link_libs}) + target_include_directories( + ${module} PUBLIC + ${CMAKE_CURRENT_LIST_DIR}/inc + ${CMAKE_SOURCE_DIR}/lib + ${inc_dirs} + ) + add_dependencies(${module} ${AUTOGEN_TARGETS}) + set(${module}_inc_dir ${CMAKE_CURRENT_LIST_DIR}/inc CACHE INTERNAL "${module} header include directory") +endfunction() + +# Create project module ROS executable +function(make_exe module srcs link_libs inc_dirs compile_defs) + set(bin_module bin_${module}) + add_executable(${bin_module} ${srcs}) + target_compile_definitions(${bin_module} PUBLIC ${compile_defs}) + ament_target_dependencies(${bin_module} PUBLIC ${ROS_DEPS}) + target_link_libraries(${bin_module} PUBLIC ${link_libs} boost_program_options) + target_include_directories( + ${bin_module} PUBLIC + ${CMAKE_CURRENT_LIST_DIR}/inc + ${CMAKE_SOURCE_DIR}/lib + ${inc_dirs} + ) + add_dependencies(${bin_module} ${AUTOGEN_TARGETS}) + install(TARGETS ${bin_module} DESTINATION lib/${PROJECT_NAME}) + # Rename the output binary to just be the module name + set_target_properties(${bin_module} PROPERTIES OUTPUT_NAME ${module}) +endfunction() + +# Create unit test +function(make_unit_test module srcs link_libs inc_dirs compile_defs) + if(UNIT_TEST) + set(test_module test_${module}) + add_executable(${test_module} ${srcs}) + target_compile_definitions(${test_module} PUBLIC ${compile_defs}) + ament_target_dependencies(${test_module} PUBLIC ${ROS_DEPS}) + target_include_directories( + ${test_module} PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/inc + ${CMAKE_SOURCE_DIR}/lib + ${inc_dirs} + ) + target_link_libraries(${test_module} PUBLIC ${GTEST_LINK_LIBS} ${link_libs}) + add_dependencies(${test_module} ${AUTOGEN_TARGETS}) + # Make the unit test runnable with CTest (invoked via test.sh) + add_test(NAME ${test_module} COMMAND ${test_module}) + endif() +endfunction() diff --git a/src/network_systems/launch/README.md b/src/network_systems/launch/README.md new file mode 100644 index 000000000..0a7fec5b2 --- /dev/null +++ b/src/network_systems/launch/README.md @@ -0,0 +1,3 @@ +# Launch + +See [Launch Configs README](../config/README.md). diff --git a/src/network_systems/launch/main_launch.py b/src/network_systems/launch/main_launch.py new file mode 100644 index 000000000..101a9ffe3 --- /dev/null +++ b/src/network_systems/launch/main_launch.py @@ -0,0 +1,206 @@ +"""Launch file that runs all nodes for the network systems ROS package.""" + +import os +from importlib.util import module_from_spec, spec_from_file_location +from typing import List, Tuple + +from launch_ros.actions import Node + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, OpaqueFunction +from launch.launch_context import LaunchContext +from launch.some_substitutions_type import SomeSubstitutionsType +from launch.substitutions import LaunchConfiguration + +# Local launch arguments and constants +PACKAGE_NAME = "network_systems" +NAMESPACE = "" +global_launch_config = "" + +# Add args with DeclareLaunchArguments object(s) and utilize in setup_launch() +LOCAL_LAUNCH_ARGUMENTS: List[DeclareLaunchArgument] = [] + + +def generate_launch_description() -> LaunchDescription: + """The launch file entry point. Generates the launch description for the `network_systems` + package. + + Returns: + LaunchDescription: The launch description. + """ + global_launch_arguments, global_environment_vars = get_global_launch_arguments() + return LaunchDescription( + [ + *global_launch_arguments, + *global_environment_vars, + *LOCAL_LAUNCH_ARGUMENTS, + OpaqueFunction(function=setup_launch), + ] + ) + + +def get_global_launch_arguments() -> Tuple: + """Gets the global launch arguments and environment variables from the global launch file. + + Returns: + Tuple: The global launch arguments and environment variables. + """ + ros_workspace = os.getenv("ROS_WORKSPACE", default="/workspaces/sailbot_workspace") + global_main_launch = os.path.join(ros_workspace, "src", "global_launch", "main_launch.py") + spec = spec_from_file_location("global_launch", global_main_launch) + if spec is None: + raise ImportError(f"Couldn't import global_launch module from {global_main_launch}") + module = module_from_spec(spec) # type: ignore[arg-type] # spec is not None + spec.loader.exec_module(module) # type: ignore[union-attr] # spec is not None + global_launch_arguments = module.GLOBAL_LAUNCH_ARGUMENTS + global_environment_vars = module.ENVIRONMENT_VARIABLES + global global_launch_config + global_launch_config = module.GLOBAL_LAUNCH_CONFIG + + return global_launch_arguments, global_environment_vars + + +def setup_launch(context: LaunchContext) -> List[Node]: + """Collects launch descriptions that describe the system behavior in the `network_systems` + package. + + Args: + context (LaunchContext): The current launch context. + + Returns: + List[Nodes]: Nodes to launch. + """ + launch_description_entities = list() + launch_description_entities.append(get_cached_fib_description(context)) + launch_description_entities.append(get_mock_ais_description(context)) + launch_description_entities.append(get_can_transceiver_description(context)) + launch_description_entities.append(get_remote_transceiver_description(context)) + return launch_description_entities + + +def get_cached_fib_description(context: LaunchContext) -> Node: + """Gets the launch description for the cached_fib_node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the cached_fib_node. + """ + node_name = "cached_fib_node" + ros_parameters = [ + global_launch_config, + {"mode": LaunchConfiguration("mode")}, + *LaunchConfiguration("config").perform(context).split(","), + ] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + + node = Node( + package=PACKAGE_NAME, + namespace=NAMESPACE, + executable="example", + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + ) + + return node + + +def get_mock_ais_description(context: LaunchContext) -> Node: + """Gets the launch description for the mock_ais_node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the mock_ais_node. + """ + node_name = "mock_ais_node" + ros_parameters = [ + global_launch_config, + {"mode": LaunchConfiguration("mode")}, + *LaunchConfiguration("config").perform(context).split(","), + ] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + + node = Node( + package=PACKAGE_NAME, + namespace=NAMESPACE, + executable="mock_ais", + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + ) + + return node + + +def get_can_transceiver_description(context: LaunchContext) -> Node: + """Gets the launch description for the can_transceiver_node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the can_transceiver_node. + """ + node_name = "can_transceiver_node" + ros_parameters = [ + global_launch_config, + {"mode": LaunchConfiguration("mode")}, + *LaunchConfiguration("config").perform(context).split(","), + ] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + + node = Node( + package=PACKAGE_NAME, + namespace=NAMESPACE, + executable="can_transceiver", + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + ) + + return node + + +def get_remote_transceiver_description(context: LaunchContext) -> Node: + """Gets the launch description for the remote_transceiver_node. + + Args: + context (LaunchContext): The current launch context. + + Returns: + Node: The node object that launches the remote_transceiver_node. + """ + node_name = "remote_transceiver_node" + ros_parameters = [ + global_launch_config, + {"mode": LaunchConfiguration("mode")}, + *LaunchConfiguration("config").perform(context).split(","), + ] + ros_arguments: List[SomeSubstitutionsType] = [ + "--log-level", + [f"{node_name}:=", LaunchConfiguration("log_level")], + ] + + node = Node( + package=PACKAGE_NAME, + namespace=NAMESPACE, + executable="remote_transceiver", + name=node_name, + parameters=ros_parameters, + ros_arguments=ros_arguments, + ) + + return node diff --git a/src/network_systems/lib/CMakeLists.txt b/src/network_systems/lib/CMakeLists.txt new file mode 100755 index 000000000..d05824d0c --- /dev/null +++ b/src/network_systems/lib/CMakeLists.txt @@ -0,0 +1,5 @@ +add_subdirectory(cmn_hdrs) +add_subdirectory(protofiles) +add_subdirectory(sailbot_db) + +# add directories as needed diff --git a/src/network_systems/lib/README.md b/src/network_systems/lib/README.md new file mode 100755 index 000000000..066dc3cae --- /dev/null +++ b/src/network_systems/lib/README.md @@ -0,0 +1,12 @@ +# lib + +Shared libraries used by the various Network Systems projects that do not generate their own executable. + +## cmn_hdrs + +Headers that contain information useful for multiple modules in Network Systems. + +## protofiles + +[Google Protocol Buffer](https://protobuf.dev/) files. These files define our serializable datatypes to be transmitted +over satellite. diff --git a/src/network_systems/lib/cmn_hdrs/CMakeLists.txt b/src/network_systems/lib/cmn_hdrs/CMakeLists.txt new file mode 100644 index 000000000..bb66d729c --- /dev/null +++ b/src/network_systems/lib/cmn_hdrs/CMakeLists.txt @@ -0,0 +1,9 @@ +# Generate ROS info header file +set(ROS_INFO_FILE ${CMAKE_SOURCE_DIR}/ros_info.txt) +add_custom_command( + OUTPUT ${CMAKE_SOURCE_DIR}/lib/cmn_hdrs/ros_info.h + COMMAND ${CMAKE_SOURCE_DIR}/scripts/autogen_ros_topics.sh ${ROS_INFO_FILE} + DEPENDS ${CMAKE_SOURCE_DIR} ${ROS_INFO_FILE} +) +add_custom_target(ros_info_h DEPENDS ${CMAKE_CURRENT_LIST_DIR}/ros_info.h) +set(AUTOGEN_TARGETS "ros_info_h" CACHE INTERNAL "Set autogenerated targets") diff --git a/src/network_systems/lib/cmn_hdrs/shared_constants.h b/src/network_systems/lib/cmn_hdrs/shared_constants.h new file mode 100644 index 000000000..e66f9deef --- /dev/null +++ b/src/network_systems/lib/cmn_hdrs/shared_constants.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +/** + * ROS argument value for system mode. An enum would be a better way of representing a binary choice between the two + * options, but since strings are not integral types they cannot be made into enums. + */ +namespace SYSTEM_MODE +{ +static const std::string PROD = "production"; +static const std::string DEV = "development"; +}; // namespace SYSTEM_MODE + +constexpr unsigned int MAX_LOCAL_TO_REMOTE_PAYLOAD_SIZE_BYTES = 340; +constexpr unsigned int MAX_REMOTE_TO_LOCAL_PAYLOAD_SIZE_BYTES = 270; + +constexpr int NUM_BATTERIES = []() constexpr +{ + using batteries_arr = custom_interfaces::msg::Batteries::_batteries_type; + return sizeof(batteries_arr) / sizeof(custom_interfaces::msg::HelperBattery); +} +(); +constexpr int NUM_WIND_SENSORS = []() constexpr +{ + using wind_sensors_arr = custom_interfaces::msg::WindSensors::_wind_sensors_type; + return sizeof(wind_sensors_arr) / sizeof(custom_interfaces::msg::WindSensor); +} +(); + +/****** Upper and lower bounds ******/ + +/***** Bounds for Latitude and Longitude ******/ +constexpr float LAT_LBND = -90.0; +constexpr float LAT_UBND = 90.0; +constexpr float LON_LBND = -180.0; +constexpr float LON_UBND = 180.0; + +/***** Bounds for Speed ******/ +constexpr float SPEED_LBND = -10.0; // Placeholder number +constexpr float SPEED_UBND = 10.0; // Placeholder number + +/***** Bounds for Heading ******/ +constexpr float HEADING_LBND = 0.0; +constexpr float HEADING_UBND = 360.0; + +// boat rotation +// See https://documentation.spire.com/ais-fundamentals/rate-of-turn-rot/ for how ROT works +constexpr int8_t ROT_LBND = -126; +constexpr int8_t ROT_UBND = 126; + +// boat dimension +constexpr float SHIP_DIMENSION_LBND = 1; // arbitrary number +constexpr float SHIP_DIMENSION_UBND = 650.0; // arbitrary number + +/***** Bounds for Battery ******/ +constexpr float BATT_VOLT_LBND = 0.5; // Placeholder number +constexpr float BATT_VOLT_UBND = 250.0; // Placeholder number +constexpr float BATT_CURR_LBND = -200.0; // Placeholder number +constexpr float BATT_CURR_UBND = 200.0; // Placeholder number + +/***** Bounds for Wind Sensor ******/ +constexpr int WIND_DIRECTION_LBND = -180; +constexpr int WIND_DIRECTION_UBND = 179; diff --git a/src/network_systems/lib/protofiles/CMakeLists.txt b/src/network_systems/lib/protofiles/CMakeLists.txt new file mode 100755 index 000000000..5b0a5f2ba --- /dev/null +++ b/src/network_systems/lib/protofiles/CMakeLists.txt @@ -0,0 +1,19 @@ +# disable clang tidy checks for autogenerated protobuf files +set(CMAKE_CXX_CLANG_TIDY "") + +# Generate and add protobuf libraries +file(GLOB ProtoFiles "${CMAKE_CURRENT_LIST_DIR}/*.proto") +protobuf_generate_cpp(ProtoSrcs ProtoHdrs ${ProtoFiles}) +add_library(protofiles STATIC ${ProtoSrcs} ${ProtoHdrs}) +target_include_directories(protofiles PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) +target_link_libraries(protofiles ${PROTOBUF_LIBRARY}) + +# The generated headers are placed in the binary directory +# save the path so we can include/link it +set(PROTOBUF_INCLUDE_PATH ${CMAKE_CURRENT_BINARY_DIR} + CACHE INTERNAL "Path to generated protobuf files") + +# re-enable clang tidy checks +if(STATIC_ANALYSIS) + set(CMAKE_CXX_CLANG_TIDY "clang-tidy-${CLANG_VERSION}") +endif() diff --git a/src/network_systems/lib/protofiles/global_path.proto b/src/network_systems/lib/protofiles/global_path.proto new file mode 100644 index 000000000..76ed74a10 --- /dev/null +++ b/src/network_systems/lib/protofiles/global_path.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package Polaris; + +import "waypoint.proto"; + +message GlobalPath +{ + uint32 num_waypoints = 1; + repeated Waypoint waypoints = 2; +} diff --git a/src/network_systems/lib/protofiles/sensors.proto b/src/network_systems/lib/protofiles/sensors.proto new file mode 100755 index 000000000..767da3464 --- /dev/null +++ b/src/network_systems/lib/protofiles/sensors.proto @@ -0,0 +1,59 @@ +syntax = "proto3"; + +package Polaris; + +import "waypoint.proto"; + +message Sensors +{ + message Gps + { + float latitude = 1; + float longitude = 2; + float speed = 3; + float heading = 4; + } + + message Wind + { + float speed = 1; + int32 direction = 2; + } + + message Battery + { + float voltage = 1; + float current = 2; + } + + message Ais + { + uint32 id = 1; + float latitude = 2; + float longitude = 3; + float sog = 4; + float cog = 5; + float rot = 6; + float width = 7; + float length = 8; + } + + message Generic + { + uint32 id = 1; + uint64 data = 2; + } + + message Path + { + repeated Waypoint waypoints = 1; + // Will be expanded in the future + } + + Gps gps = 1; + repeated Wind wind_sensors = 2; + repeated Battery batteries = 3; + repeated Ais ais_ships = 4; + repeated Generic data_sensors = 5; + Path local_path_data = 6; +} diff --git a/src/network_systems/lib/protofiles/waypoint.proto b/src/network_systems/lib/protofiles/waypoint.proto new file mode 100644 index 000000000..171a6533c --- /dev/null +++ b/src/network_systems/lib/protofiles/waypoint.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package Polaris; + +message Waypoint +{ + float latitude = 1; + float longitude = 2; +} diff --git a/src/network_systems/lib/sailbot_db/CMakeLists.txt b/src/network_systems/lib/sailbot_db/CMakeLists.txt new file mode 100644 index 000000000..43c9be13b --- /dev/null +++ b/src/network_systems/lib/sailbot_db/CMakeLists.txt @@ -0,0 +1,36 @@ +set(module sailbot_db) + +set(link_libs + ${PROTOBUF_LINK_LIBS} + mongo::mongocxx_shared + mongo::bsoncxx_shared +) + +set(inc_dirs + ${PROTOBUF_INCLUDE_PATH} + ${LIBMONGOCXX_INCLUDE_DIRS} + ${LIBBSONCXX_INCLUDE_DIRS} +) + +set(srcs + ${CMAKE_CURRENT_LIST_DIR}/src/sailbot_db.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/util_db.cpp +) + +# make sailbot_db library +make_lib(${module} "${srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +set(bin_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp +) + +# Make executable +make_exe(${module} "${bin_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +# Create unit test +set(test_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/test/test_sailbot_db.cpp +) +make_unit_test(${module} "${test_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") diff --git a/src/network_systems/lib/sailbot_db/inc/sailbot_db.h b/src/network_systems/lib/sailbot_db/inc/sailbot_db.h new file mode 100644 index 000000000..b429d5284 --- /dev/null +++ b/src/network_systems/lib/sailbot_db/inc/sailbot_db.h @@ -0,0 +1,175 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "sensors.pb.h" +#include "waypoint.pb.h" + +// >>>>IMPORTANT<<<<< +// BSON document formats from: https://ubcsailbot.atlassian.net/wiki/spaces/prjt22/pages/1907589126/Database+Schemas: + +const std::string COLLECTION_AIS_SHIPS = "ais_ships"; +const std::string COLLECTION_BATTERIES = "batteries"; +const std::string COLLECTION_DATA_SENSORS = "data_sensors"; +const std::string COLLECTION_GPS = "gps"; +const std::string COLLECTION_WIND_SENSORS = "wind_sensors"; +const std::string COLLECTION_LOCAL_PATH = "local_path"; +const std::string MONGODB_CONN_STR = "mongodb://localhost:27017"; + +template +using ProtoList = google::protobuf::RepeatedPtrField; +using DocVal = bsoncxx::document::view_or_value; + +/** + * Thread-safe class that encapsulates a Sailbot MongoDB database + * + */ +class SailbotDB +{ +public: + /** + * Structure to represent metadata associated with a received Iridium message + */ + struct RcvdMsgInfo + { + float lat_; // Transmission latitude + float lon_; // Transmission longitude + uint32_t cep_; // Transmission accuracy (km) + std::string timestamp_; // Transmission time (-- ::) + + /** + * @brief overload stream operator + */ + friend std::ostream & operator<<(std::ostream & os, const RcvdMsgInfo & info); + + /** + * @brief Get a properly formatted timestamp string + * + * @param tm standard C/C++ time structure + * @return tm converted to a timestamp string + */ + static std::string mkTimestamp(const std::tm & tm); + }; + + /** + * @brief Construct a new SailbotDB object + * + * @param db_name name of desired database + * @param mongodb_conn_str URL for mongodb database (ex. mongodb://localhost:27017) + */ + SailbotDB(const std::string & db_name, const std::string & mongodb_conn_str); + + /** + * @brief Format and print a document in the DB + * + * @param doc document value + */ + static void printDoc(const DocVal & doc); + + /** + * @brief Ping the connected database to see if the connection succeeded + * + * @return true if ping is successful + * @return false if ping fails + */ + bool testConnection(); + + /** + * @brief Write new sensor data to the database + * + * @param sensors_pb Protobuf Sensors object + * @param new_info Transmission information for the new data + * + * @return true if successful + * @return false on failure + */ + bool storeNewSensors(const Polaris::Sensors & sensors_pb, RcvdMsgInfo new_info); + +protected: + const std::string db_name_; // Name of the database + std::unique_ptr pool_; // pool of clients for thread safety + +private: + static mongocxx::instance inst_; // MongoDB instance (must be present - there can only ever be one) + + /** + * @brief Write GPS data to the database + * + * @param gps_pb Protobuf GPS object + * @param timestamp transmission time -- :: + * @param client mongocxx::client instance for the current thread + * + * @return true if successful + * @return false on failure + */ + bool storeGps(const Polaris::Sensors::Gps & gps_pb, const std::string & timestamp, mongocxx::client & client); + + /** + * @brief Write AIS data to the database + * + * @param ais_ships_pb Protobuf list of AIS objects, where the size of the list is the number of ships + * @param timestamp transmission time -- :: + * @param client mongocxx::client instance for the current thread + + * @return true if successful + * @return false on failure + */ + bool storeAis( + const ProtoList & ais_ships_pb, const std::string & timestamp, mongocxx::client & client); + + /** + * @brief Write path sensor data to the database + * + * @param generic_pb Protobuf list of path sensor objects, where the size of the list is the number of path sensors + * @param timestamp transmission time -- :: + * @param client mongocxx::client instance for the current thread + + * @return true if successful + * @return false on failure + */ + bool storePathSensors( + const Polaris::Sensors::Path & local_path_pb, const std::string & timestamp, mongocxx::client & client); + + /** + * @brief Adds generic sensors to the database + * + * @param generic_pb Protobuf list of generic sensor objects, where the size of the list is the number of sensors + * @param timestamp transmission time -- :: + * @param client mongocxx::client instance for the current thread + * + * @return True if sensor is added, false otherwise + */ + bool storeGenericSensors( + const ProtoList & generic_pb, const std::string & timestamp, + mongocxx::client & client); + + /** + * @brief Adds a battery sensors to the database + * + * @param generic_pb Protobuf list of battery objects + * @param timestamp transmission time -- :: + * @param client mongocxx::client instance for the current thread + * + * @return True if sensor is added, false otherwise + */ + bool storeBatteries( + const ProtoList & battery_pb, const std::string & timestamp, + mongocxx::client & client); + + /** + * @brief Adds a wind sensor to the database flow + * + * @param generic_pb Protobuf list of wind sensor objects + * @param timestamp transmission time -- :: + * @param client mongocxx::client instance for the current thread + * + * @return True if sensor is added, false otherwise + */ + bool storeWindSensors( + const ProtoList & wind_pb, const std::string & timestamp, mongocxx::client & client); +}; diff --git a/src/network_systems/lib/sailbot_db/inc/util_db.h b/src/network_systems/lib/sailbot_db/inc/util_db.h new file mode 100644 index 000000000..d4d4d4f69 --- /dev/null +++ b/src/network_systems/lib/sailbot_db/inc/util_db.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include + +#include "sailbot_db.h" +#include "sensors.pb.h" +#include "utils/utils.h" + +class UtilDB : public SailbotDB +{ +public: + static constexpr int NUM_AIS_SHIPS = 15; // arbitrary number + static constexpr int NUM_GENERIC_SENSORS = 5; // arbitrary number + static constexpr int NUM_PATH_WAYPOINTS = 5; // arbitrary number + + /** + * @brief Construct a UtilDB, which has debug utilities for SailbotDB + * + * @param db_name + * @param mongodb_conn_str + * @param rng + */ + UtilDB(const std::string & db_name, const std::string & mongodb_conn_str, std::shared_ptr rng); + + /** + * @brief Delete all documents in all collections + */ + void cleanDB(); + + /** + * @brief Generate random data for all sensors + * + * @return Sensors object + */ + Polaris::Sensors genRandSensors(); + + /** + * @return timestamp for the current time + */ + static std::tm getTimestamp(); + + /** + * @brief Generate random sensors and Iridium msg info + * + * @param tm Timestamp returned by getTimestamp() (with any modifications made to it) + * @return std::pair + */ + std::pair genRandData(const std::tm & tm); + + /** + * @brief Query the database and check that the sensor and message are correct + * + * @param expected_sensors + * @param expected_msg_info + */ + bool verifyDBWrite( + std::span expected_sensors, std::span expected_msg_info); + + /** + * @brief Dump and check all sensors and timestamps from the database + * + * @param tracker FailureTracker that gets if any unexpected results are dumped + * @param expected_num_docs Expected number of documents. tracker is updated if there's a mismatch + * @return std::pair{Vector of dumped Sensors, Vector of dumped timestamps} + */ + std::pair, std::vector> dumpSensors( + utils::FailTracker & tracker, size_t expected_num_docs = 1); + +private: + std::shared_ptr rng_; // random number generator + + /** + * @brief generate random GPS data + * + * @param gps_data GPS data to modify + */ + void genRandGpsData(Polaris::Sensors::Gps & gps_data); + + /** + * @brief generate random ais ships data + * + * @param ais_ship AIS ship data to modify + */ + void genRandAisData(Polaris::Sensors::Ais & ais_ship); + + /** + * @brief generate random generic sensor data + * + * @param generic_sensor Generic sensor data to modify + */ + void genRandGenericSensorData(Polaris::Sensors::Generic & generic_sensor); + + /** + * @brief generate random battery data + * + * @param battery battery data to modify + */ + void genRandBatteryData(Polaris::Sensors::Battery & battery); + + /** + * @brief generate random wind sensors data + * + * @param wind_data Wind sensor data to modify + */ + void genRandWindData(Polaris::Sensors::Wind & wind_data); + + /** + * @brief generate random path data + * + * @param path_data Path data to modify + */ + void genRandPathData(Polaris::Sensors::Path & path_data); +}; diff --git a/src/network_systems/lib/sailbot_db/src/main.cpp b/src/network_systems/lib/sailbot_db/src/main.cpp new file mode 100644 index 000000000..56249c374 --- /dev/null +++ b/src/network_systems/lib/sailbot_db/src/main.cpp @@ -0,0 +1,126 @@ +#include + +#include "util_db.h" + +namespace po = boost::program_options; + +enum class CLIOpt { Help, Clear, Populate, Seed, DumpSensors, DumpGlobalPath, DBName }; + +std::string to_string(CLIOpt c) +{ + switch (c) { + case CLIOpt::Help: + return "help"; + case CLIOpt::Clear: + return "clear"; + case CLIOpt::Populate: + return "populate"; + case CLIOpt::Seed: + return "seed"; + case CLIOpt::DumpSensors: + return "dump-sensors"; + case CLIOpt::DumpGlobalPath: + return "dump-global-path"; + case CLIOpt::DBName: + return "db-name"; + } +}; + +const std::map CLIOptDesc{ + {CLIOpt::Help, "Help message"}, + {CLIOpt::Clear, "Clear the contents of a database collection"}, + {CLIOpt::Populate, "Populate a database collection with random data"}, + {CLIOpt::Seed, "(Optional) Unsigned integer random seed to generate random data used to populate db collection"}, + {CLIOpt::DumpSensors, "Dump latest sensor data in the database collection"}, + {CLIOpt::DumpGlobalPath, "Dump latest Global Path stored in the database collection"}, + {CLIOpt::DBName, "Name of db collection to target"}, +}; + +int main(int argc, char ** argv) +{ + try { + // Formatting is weird, see: https://www.boost.org/doc/libs/1_63_0/doc/html/program_options/tutorial.html + po::options_description o_desc("COMMAND(s)"); + // The ",h" allows for a shortened -h flag + o_desc.add_options()((to_string(CLIOpt::Help) + ",h").c_str(), CLIOptDesc.at(CLIOpt::Help).c_str()); + o_desc.add_options()(to_string(CLIOpt::Clear).c_str(), CLIOptDesc.at(CLIOpt::Clear).c_str()); + o_desc.add_options()(to_string(CLIOpt::Populate).c_str(), CLIOptDesc.at(CLIOpt::Populate).c_str()); + o_desc.add_options()( + to_string(CLIOpt::Seed).c_str(), po::value(), CLIOptDesc.at(CLIOpt::Seed).c_str()); + o_desc.add_options()(to_string(CLIOpt::DumpSensors).c_str(), CLIOptDesc.at(CLIOpt::DumpSensors).c_str()); + o_desc.add_options()(to_string(CLIOpt::DumpGlobalPath).c_str(), CLIOptDesc.at(CLIOpt::DumpGlobalPath).c_str()); + o_desc.add_options()( + to_string(CLIOpt::DBName).c_str(), po::value(), CLIOptDesc.at(CLIOpt::DBName).c_str()); + + // Make DBName a positional argument so we don't have to specify --db-name + po::positional_options_description po_desc; + po_desc.add(to_string(CLIOpt::DBName).c_str(), -1); + + po::variables_map vm; + po::store(po::command_line_parser(argc, argv).options(o_desc).positional(po_desc).run(), vm); + po::notify(vm); + + const std::string usage_instructions = [&o_desc]() { + std::stringstream ss; + ss << "Usage: sailbot_db DB-NAME [COMMAND]\n\n" + // Need to separately print that DB-NAME is a positional argument + << "DB-NAME: " << CLIOptDesc.at(CLIOpt::DBName) << "\n\n" + << o_desc << std::endl; + return ss.str(); + }(); + + if (vm.count(to_string(CLIOpt::Help)) != 0) { + std::cout << usage_instructions << std::endl; + return 0; + } + + if (vm.count(to_string(CLIOpt::DBName)) == 0) { + std::cerr << usage_instructions << std::endl; + return -1; + } + + uint32_t seed; + if (vm.count(to_string(CLIOpt::Seed)) != 0) { + seed = vm[to_string(CLIOpt::Seed)].as(); + } else { + seed = std::random_device()(); // initialize seed with random device value + } + std::mt19937 mt(seed); + + std::string db_name = vm[to_string(CLIOpt::DBName)].as(); + UtilDB db(db_name, MONGODB_CONN_STR, std::make_shared(mt)); + + if (!db.testConnection()) { + std::cerr << "Failed to establish connection to DB \"" << db_name << "\"" << std::endl; + return -1; + } + + if (vm.count(to_string(CLIOpt::Clear)) != 0) { + db.cleanDB(); + } + + if (vm.count(to_string(CLIOpt::Populate)) != 0) { + std::cout << "Populating random sensors with seed: " << seed << std::endl; + auto [rand_sensors, info] = db.genRandData(UtilDB::getTimestamp()); + db.storeNewSensors(rand_sensors, info); + // TODO(hsn200406,vaibhavambastha): Add code to store global path + } + + if (vm.count(to_string(CLIOpt::DumpSensors)) != 0) { + utils::FailTracker t; + auto [sensors_vec, timestamp_vec] = db.dumpSensors(t, 1); + std::cout << "Latest sensors:\n\n" << sensors_vec.at(sensors_vec.size() - 1).DebugString() << std::endl; + std::cout << "Timestamp: " << timestamp_vec.at(sensors_vec.size() - 1) << std::endl; + } + + if (vm.count(to_string(CLIOpt::DumpGlobalPath)) != 0) { + std::cerr << "Dump global path not implemented!" << std::endl; + // TODO(hsn200406,vaibhavambastha): Add code to dump global path + } + + return 0; + } catch (std::exception & e) { + std::cerr << e.what() << std::endl; + return -1; + } +} diff --git a/src/network_systems/lib/sailbot_db/src/sailbot_db.cpp b/src/network_systems/lib/sailbot_db/src/sailbot_db.cpp new file mode 100644 index 000000000..588b5a090 --- /dev/null +++ b/src/network_systems/lib/sailbot_db/src/sailbot_db.cpp @@ -0,0 +1,178 @@ +#include "sailbot_db.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sensors.pb.h" +#include "waypoint.pb.h" + +namespace bstream = bsoncxx::builder::stream; +using Polaris::Sensors; + +mongocxx::instance SailbotDB::inst_{}; // staticallly initialize instance + +// PUBLIC + +std::ostream & operator<<(std::ostream & os, const SailbotDB::RcvdMsgInfo & info) +{ + os << "Latitude: " << info.lat_ << "\n" + << "Longitude: " << info.lon_ << "\n" + << "Accuracy (km): " << info.cep_ << "\n" + << "Timestamp: " << info.timestamp_; + return os; +} + +std::string SailbotDB::RcvdMsgInfo::mkTimestamp(const std::tm & tm) +{ + // This is impossible to read. It's reading each field of tm and 0 padding it to 2 digits with either "-" or ":" + // in between each number + std::stringstream tm_ss; + tm_ss << std::setfill('0') << std::setw(2) << tm.tm_year << "-" << std::setfill('0') << std::setw(2) << tm.tm_mon + << "-" << std::setfill('0') << std::setw(2) << tm.tm_mday << " " << std::setfill('0') << std::setw(2) + << tm.tm_hour << ":" << std::setfill('0') << std::setw(2) << tm.tm_min << ":" << std::setfill('0') + << std::setw(2) << tm.tm_sec; + return tm_ss.str(); +} + +SailbotDB::SailbotDB(const std::string & db_name, const std::string & mongodb_conn_str) : db_name_(db_name) +{ + mongocxx::uri uri = mongocxx::uri{mongodb_conn_str}; + pool_ = std::make_unique(uri); +} + +void SailbotDB::printDoc(const DocVal & doc) { std::cout << bsoncxx::to_json(doc.view()) << std::endl; } + +bool SailbotDB::testConnection() +{ + const DocVal ping_cmd = bstream::document{} << "ping" << 1 << bstream::finalize; + mongocxx::pool::entry entry = pool_->acquire(); + mongocxx::database db = (*entry)[db_name_]; + try { + // Ping the database. + db.run_command(ping_cmd.view()); + return true; + } catch (const std::exception & e) { + std::cout << "Exception: " << e.what() << std::endl; + return false; + } +} + +bool SailbotDB::storeNewSensors(const Sensors & sensors_pb, RcvdMsgInfo new_info) +{ + // Only using timestamp info for now, may use other fields in the future + const std::string & timestamp = new_info.timestamp_; + mongocxx::pool::entry entry = pool_->acquire(); + return storeGps(sensors_pb.gps(), timestamp, *entry) && storeAis(sensors_pb.ais_ships(), timestamp, *entry) && + storeGenericSensors(sensors_pb.data_sensors(), timestamp, *entry) && + storeBatteries(sensors_pb.batteries(), timestamp, *entry) && + storeWindSensors(sensors_pb.wind_sensors(), timestamp, *entry) && + storePathSensors(sensors_pb.local_path_data(), timestamp, *entry); +} + +// END PUBLIC + +// PRIVATE + +bool SailbotDB::storeGps(const Sensors::Gps & gps_pb, const std::string & timestamp, mongocxx::client & client) +{ + mongocxx::database db = client[db_name_]; + mongocxx::collection gps_coll = db[COLLECTION_GPS]; + DocVal gps_doc = bstream::document{} << "latitude" << gps_pb.latitude() << "longitude" << gps_pb.longitude() + << "speed" << gps_pb.speed() << "heading" << gps_pb.heading() << "timestamp" + << timestamp << bstream::finalize; + + return static_cast(gps_coll.insert_one(gps_doc.view())); +} + +bool SailbotDB::storeAis( + const ProtoList & ais_ships_pb, const std::string & timestamp, mongocxx::client & client) +{ + mongocxx::database db = client[db_name_]; + mongocxx::collection ais_coll = db[COLLECTION_AIS_SHIPS]; + bstream::document doc_builder{}; + auto ais_ships_doc_arr = doc_builder << "ships" << bstream::open_array; + for (const Sensors::Ais & ais_ship : ais_ships_pb) { + // The BSON spec does not allow unsigned integers (throws exception), so cast our uint32s to sint64s + ais_ships_doc_arr = ais_ships_doc_arr + << bstream::open_document << "id" << static_cast(ais_ship.id()) << "latitude" + << ais_ship.latitude() << "longitude" << ais_ship.longitude() << "sog" << ais_ship.sog() + << "cog" << ais_ship.cog() << "rot" << ais_ship.rot() << "width" << ais_ship.width() + << "length" << ais_ship.length() << bstream::close_document; + } + DocVal ais_ships_doc = ais_ships_doc_arr << bstream::close_array << "timestamp" << timestamp << bstream::finalize; + return static_cast(ais_coll.insert_one(ais_ships_doc.view())); +} + +bool SailbotDB::storeGenericSensors( + const ProtoList & generic_pb, const std::string & timestamp, mongocxx::client & client) +{ + mongocxx::database db = client[db_name_]; + mongocxx::collection generic_coll = db[COLLECTION_DATA_SENSORS]; + bstream::document doc_builder{}; + auto generic_doc_arr = doc_builder << "genericSensors" << bstream::open_array; + for (const Sensors::Generic & generic : generic_pb) { + generic_doc_arr = generic_doc_arr << bstream::open_document << "id" << static_cast(generic.id()) + << "data" << static_cast(generic.data()) << bstream::close_document; + } + DocVal generic_doc = generic_doc_arr << bstream::close_array << "timestamp" << timestamp << bstream::finalize; + return static_cast(generic_coll.insert_one(generic_doc.view())); +} + +bool SailbotDB::storeBatteries( + const ProtoList & battery_pb, const std::string & timestamp, mongocxx::client & client) +{ + mongocxx::database db = client[db_name_]; + mongocxx::collection batteries_coll = db[COLLECTION_BATTERIES]; + bstream::document doc_builder{}; + auto batteries_doc_arr = doc_builder << "batteries" << bstream::open_array; + for (const Sensors::Battery & battery : battery_pb) { + batteries_doc_arr = batteries_doc_arr << bstream::open_document << "voltage" << battery.voltage() << "current" + << battery.current() << bstream::close_document; + } + DocVal batteries_doc = batteries_doc_arr << bstream::close_array << "timestamp" << timestamp << bstream::finalize; + return static_cast(batteries_coll.insert_one(batteries_doc.view())); +} + +bool SailbotDB::storeWindSensors( + const ProtoList & wind_pb, const std::string & timestamp, mongocxx::client & client) +{ + mongocxx::database db = client[db_name_]; + mongocxx::collection wind_coll = db[COLLECTION_WIND_SENSORS]; + bstream::document doc_builder{}; + auto wind_doc_arr = doc_builder << "windSensors" << bstream::open_array; + for (const Sensors::Wind & wind_sensor : wind_pb) { + wind_doc_arr = wind_doc_arr << bstream::open_document << "speed" << wind_sensor.speed() << "direction" + << static_cast(wind_sensor.direction()) << bstream::close_document; + } + DocVal wind_doc = wind_doc_arr << bstream::close_array << "timestamp" << timestamp << bstream::finalize; + return static_cast(wind_coll.insert_one(wind_doc.view())); +} + +bool SailbotDB::storePathSensors( + const Sensors::Path & local_path_pb, const std::string & timestamp, mongocxx::client & client) +{ + mongocxx::database db = client[db_name_]; + mongocxx::collection local_path_coll = db[COLLECTION_LOCAL_PATH]; + bstream::document doc_builder{}; + auto local_path_doc_arr = doc_builder << "waypoints" << bstream::open_array; + ProtoList waypoints = local_path_pb.waypoints(); + for (const Polaris::Waypoint & waypoint : waypoints) { + local_path_doc_arr = local_path_doc_arr << bstream::open_document << "latitude" << waypoint.latitude() + << "longitude" << waypoint.longitude() << bstream::close_document; + } + DocVal local_path_doc = local_path_doc_arr << bstream::close_array << "timestamp" << timestamp << bstream::finalize; + return static_cast(local_path_coll.insert_one(local_path_doc.view())); +} + +// END PRIVATE diff --git a/src/network_systems/lib/sailbot_db/src/util_db.cpp b/src/network_systems/lib/sailbot_db/src/util_db.cpp new file mode 100644 index 000000000..e74e8341d --- /dev/null +++ b/src/network_systems/lib/sailbot_db/src/util_db.cpp @@ -0,0 +1,399 @@ + +#include "util_db.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "utils/utils.h" + +using Polaris::Sensors; + +void UtilDB::cleanDB() +{ + mongocxx::pool::entry entry = pool_->acquire(); + mongocxx::database db = (*entry)[db_name_]; + + mongocxx::collection gps_coll = db[COLLECTION_GPS]; + mongocxx::collection ais_coll = db[COLLECTION_AIS_SHIPS]; + mongocxx::collection generic_coll = db[COLLECTION_DATA_SENSORS]; + mongocxx::collection batteries_coll = db[COLLECTION_BATTERIES]; + mongocxx::collection wind_coll = db[COLLECTION_WIND_SENSORS]; + mongocxx::collection local_path_coll = db[COLLECTION_LOCAL_PATH]; + + gps_coll.delete_many(bsoncxx::builder::basic::make_document()); + ais_coll.delete_many(bsoncxx::builder::basic::make_document()); + generic_coll.delete_many(bsoncxx::builder::basic::make_document()); + batteries_coll.delete_many(bsoncxx::builder::basic::make_document()); + wind_coll.delete_many(bsoncxx::builder::basic::make_document()); + local_path_coll.delete_many(bsoncxx::builder::basic::make_document()); +} + +Sensors UtilDB::genRandSensors() +{ + Sensors sensors; + + // gps + genRandGpsData(*sensors.mutable_gps()); + + // ais ships, TODO(): Polaris should be included as one of the AIS ships + for (int i = 0; i < NUM_AIS_SHIPS; i++) { + genRandAisData(*sensors.add_ais_ships()); + } + + // generic sensors + for (int i = 0; i < NUM_GENERIC_SENSORS; i++) { + genRandGenericSensorData(*sensors.add_data_sensors()); + } + + // batteries + for (int i = 0; i < NUM_BATTERIES; i++) { + genRandBatteryData(*sensors.add_batteries()); + } + + // wind sensors + for (int i = 0; i < NUM_WIND_SENSORS; i++) { + genRandWindData(*sensors.add_wind_sensors()); + } + + // path waypoints + genRandPathData(*sensors.mutable_local_path_data()); + + return sensors; +} + +std::tm UtilDB::getTimestamp() +{ + // Get the current time + std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + std::tm * tm = std::gmtime(&t); // NOLINT(concurrency-mt-unsafe) + // tm stores years since 1900 by default, the schema expects years since 2000 + tm->tm_year -= 100; // NOLINT(readability-magic-numbers) + return *tm; +} + +std::pair UtilDB::genRandData(const std::tm & tm) +{ + Sensors rand_sensors = genRandSensors(); + + SailbotDB::RcvdMsgInfo rand_info{ + .lat_ = 0, // Not processed yet, so just set to 0 + .lon_ = 0, // Not processed yet, so just set to 0 + .cep_ = 0, // Not processed yet, so just set to 0 + .timestamp_ = SailbotDB::RcvdMsgInfo::mkTimestamp(tm)}; + return {rand_sensors, rand_info}; +} + +bool UtilDB::verifyDBWrite(std::span expected_sensors, std::span expected_msg_info) +{ + utils::FailTracker tracker; + + auto expectEQ = [&tracker](T rcvd, T expected, const std::string & err_msg) -> void { + tracker.track(utils::checkEQ(rcvd, expected, err_msg)); + }; + auto expectFloatEQ = [&tracker](T rcvd, T expected, const std::string & err_msg) -> void { + tracker.track(utils::checkEQ(rcvd, expected, err_msg)); + }; + + expectEQ(expected_sensors.size(), expected_msg_info.size(), "Must have msg info for each set of Sensors"); + size_t num_docs = expected_sensors.size(); + auto [dumped_sensors, dumped_timestamps] = dumpSensors(tracker, num_docs); + + expectEQ(dumped_sensors.size(), num_docs, ""); + expectEQ(dumped_timestamps.size(), num_docs, ""); + + for (size_t i = 0; i < num_docs; i++) { + expectEQ(dumped_timestamps[i], expected_msg_info[i].timestamp_, ""); + + // gps + expectFloatEQ(dumped_sensors[i].gps().latitude(), expected_sensors[i].gps().latitude(), ""); + expectFloatEQ(dumped_sensors[i].gps().longitude(), expected_sensors[i].gps().longitude(), ""); + expectFloatEQ(dumped_sensors[i].gps().speed(), expected_sensors[i].gps().speed(), ""); + expectFloatEQ(dumped_sensors[i].gps().heading(), expected_sensors[i].gps().heading(), ""); + + // ais ships + for (int j = 0; j < NUM_AIS_SHIPS; j++) { + const Sensors::Ais & dumped_ais_ship = dumped_sensors[i].ais_ships(j); + const Sensors::Ais & expected_ais_ship = expected_sensors[i].ais_ships(j); + expectEQ(dumped_ais_ship.id(), expected_ais_ship.id(), ""); + expectFloatEQ(dumped_ais_ship.latitude(), expected_ais_ship.latitude(), ""); + expectFloatEQ(dumped_ais_ship.longitude(), expected_ais_ship.longitude(), ""); + expectFloatEQ(dumped_ais_ship.sog(), expected_ais_ship.sog(), ""); + expectFloatEQ(dumped_ais_ship.cog(), expected_ais_ship.cog(), ""); + expectFloatEQ(dumped_ais_ship.rot(), expected_ais_ship.rot(), ""); + expectFloatEQ(dumped_ais_ship.width(), expected_ais_ship.width(), ""); + expectFloatEQ(dumped_ais_ship.length(), expected_ais_ship.length(), ""); + } + + // generic sensors + for (int j = 0; j < NUM_GENERIC_SENSORS; j++) { + const Sensors::Generic & dumped_data_sensor = dumped_sensors[i].data_sensors(j); + const Sensors::Generic & expected_data_sensor = expected_sensors[i].data_sensors(j); + expectEQ(dumped_data_sensor.id(), expected_data_sensor.id(), ""); + expectEQ(dumped_data_sensor.data(), expected_data_sensor.data(), ""); + } + + // batteries + for (int j = 0; j < NUM_BATTERIES; j++) { + const Sensors::Battery & dumped_battery = dumped_sensors[i].batteries(j); + const Sensors::Battery & expected_battery = expected_sensors[i].batteries(j); + expectFloatEQ(dumped_battery.voltage(), expected_battery.voltage(), ""); + expectFloatEQ(dumped_battery.current(), expected_battery.current(), ""); + } + + // wind sensors + for (int j = 0; j < NUM_WIND_SENSORS; j++) { + const Sensors::Wind & dumped_wind_sensor = dumped_sensors[i].wind_sensors(j); + const Sensors::Wind & expected_wind_sensor = expected_sensors[i].wind_sensors(j); + expectFloatEQ(dumped_wind_sensor.speed(), expected_wind_sensor.speed(), ""); + expectEQ(dumped_wind_sensor.direction(), expected_wind_sensor.direction(), ""); + } + + // path waypoints + for (int j = 0; j < NUM_PATH_WAYPOINTS; j++) { + const Polaris::Waypoint & dumped_path_waypoint = dumped_sensors[i].local_path_data().waypoints(j); + const Polaris::Waypoint & expected_path_waypoint = expected_sensors[i].local_path_data().waypoints(j); + expectFloatEQ(dumped_path_waypoint.latitude(), expected_path_waypoint.latitude(), ""); + expectFloatEQ(dumped_path_waypoint.longitude(), expected_path_waypoint.longitude(), ""); + } + } + return !tracker.failed(); +} + +std::pair, std::vector> UtilDB::dumpSensors( + utils::FailTracker & tracker, size_t num_docs) +{ + auto expectEQ = [&tracker](T rcvd, T expected, const std::string & err_msg) -> void { + tracker.track(utils::checkEQ(rcvd, expected, err_msg)); + }; + + std::vector sensors_vec(num_docs); + std::vector timestamp_vec(num_docs); + mongocxx::pool::entry entry = pool_->acquire(); + mongocxx::database db = (*entry)[db_name_]; + // Set the find options to sort by timestamp + bsoncxx::document::value order = bsoncxx::builder::stream::document{} << "timestamp" << 1 + << bsoncxx::builder::stream::finalize; + mongocxx::options::find opts = mongocxx::options::find{}; + opts.sort(order.view()); + + // gps + mongocxx::collection gps_coll = db[COLLECTION_GPS]; + mongocxx::cursor gps_docs = gps_coll.find({}, opts); + expectEQ( + static_cast(gps_coll.count_documents({})), num_docs, + "Error: TestDB should only have " + std::to_string(num_docs) + " documents per collection"); + + for (auto [i, gps_docs_it] = std::tuple{size_t{0}, gps_docs.begin()}; i < num_docs; i++, gps_docs_it++) { + Sensors & sensors = sensors_vec[i]; + std::string & timestamp = timestamp_vec[i]; + const bsoncxx::document::view gps_doc = *gps_docs_it; + + Sensors::Gps * gps = sensors.mutable_gps(); + gps->set_latitude(static_cast(gps_doc["latitude"].get_double().value)); + gps->set_longitude(static_cast(gps_doc["longitude"].get_double().value)); + gps->set_speed(static_cast(gps_doc["speed"].get_double().value)); + gps->set_heading(static_cast(gps_doc["heading"].get_double().value)); + timestamp = gps_doc["timestamp"].get_utf8().value.to_string(); + } + + // ais ships + mongocxx::collection ais_coll = db[COLLECTION_AIS_SHIPS]; + mongocxx::cursor ais_docs = ais_coll.find({}, opts); + expectEQ( + static_cast(ais_coll.count_documents({})), num_docs, + "Error: TestDB should only have " + std::to_string(num_docs) + " documents per collection"); + + for (auto [i, ais_docs_it] = std::tuple{size_t{0}, ais_docs.begin()}; i < num_docs; i++, ais_docs_it++) { + Sensors & sensors = sensors_vec[i]; + const std::string & timestamp = timestamp_vec[i]; + const bsoncxx::document::view ais_ships_doc = *ais_docs_it; + + for (bsoncxx::array::element ais_ships_doc : ais_ships_doc["ships"].get_array().value) { + Sensors::Ais * ais_ship = sensors.add_ais_ships(); + ais_ship->set_id(static_cast(ais_ships_doc["id"].get_int64().value)); + ais_ship->set_latitude(static_cast(ais_ships_doc["latitude"].get_double().value)); + ais_ship->set_longitude(static_cast(ais_ships_doc["longitude"].get_double().value)); + ais_ship->set_sog(static_cast(ais_ships_doc["sog"].get_double().value)); + ais_ship->set_cog(static_cast(ais_ships_doc["cog"].get_double().value)); + ais_ship->set_rot(static_cast(ais_ships_doc["rot"].get_double().value)); + ais_ship->set_width(static_cast(ais_ships_doc["width"].get_double().value)); + ais_ship->set_length(static_cast(ais_ships_doc["length"].get_double().value)); + } + expectEQ(sensors.ais_ships().size(), NUM_AIS_SHIPS, "Size mismatch when reading AIS ships from DB"); + expectEQ(ais_ships_doc["timestamp"].get_utf8().value.to_string(), timestamp, "Document timestamp mismatch"); + } + + // generic sensor + mongocxx::collection generic_coll = db[COLLECTION_DATA_SENSORS]; + mongocxx::cursor generic_sensor_docs = generic_coll.find({}, opts); + expectEQ( + static_cast(generic_coll.count_documents({})), num_docs, + "Error: TestDB should only have " + std::to_string(num_docs) + " documents per collection"); + + for (auto [i, generic_sensor_docs_it] = std::tuple{size_t{0}, generic_sensor_docs.begin()}; i < num_docs; + i++, generic_sensor_docs_it++) { + Sensors & sensors = sensors_vec[i]; + const std::string & timestamp = timestamp_vec[i]; + const bsoncxx::document::view generic_doc = *generic_sensor_docs_it; + + for (bsoncxx::array::element generic_doc : generic_doc["genericSensors"].get_array().value) { + Sensors::Generic * generic = sensors.add_data_sensors(); + generic->set_id(static_cast(generic_doc["id"].get_int64().value)); + generic->set_data(static_cast(generic_doc["data"].get_int64().value)); + } + expectEQ(generic_doc["timestamp"].get_utf8().value.to_string(), timestamp, "Document timestamp mismatch"); + } + + // battery + mongocxx::collection batteries_coll = db[COLLECTION_BATTERIES]; + mongocxx::cursor batteries_data_docs = batteries_coll.find({}, opts); + expectEQ( + static_cast(batteries_coll.count_documents({})), num_docs, + "Error: TestDB should only have " + std::to_string(num_docs) + " documents per collection"); + + for (auto [i, batteries_doc_it] = std::tuple{size_t{0}, batteries_data_docs.begin()}; i < num_docs; + i++, batteries_doc_it++) { + Sensors & sensors = sensors_vec[i]; + const std::string & timestamp = timestamp_vec[i]; + const bsoncxx::document::view batteries_doc = *batteries_doc_it; + + for (bsoncxx::array::element batteries_doc : batteries_doc["batteries"].get_array().value) { + Sensors::Battery * battery = sensors.add_batteries(); + battery->set_voltage(static_cast(batteries_doc["voltage"].get_double().value)); + battery->set_current(static_cast(batteries_doc["current"].get_double().value)); + } + expectEQ(sensors.batteries().size(), NUM_BATTERIES, "Size mismatch when reading batteries from DB"); + expectEQ(batteries_doc["timestamp"].get_utf8().value.to_string(), timestamp, "Document timestamp mismatch"); + } + + // wind sensor + mongocxx::collection wind_coll = db[COLLECTION_WIND_SENSORS]; + mongocxx::cursor wind_sensors_docs = wind_coll.find({}, opts); + expectEQ( + static_cast(wind_coll.count_documents({})), num_docs, + "Error: TestDB should only have " + std::to_string(num_docs) + " documents per collection"); + + for (auto [i, wind_doc_it] = std::tuple{size_t{0}, wind_sensors_docs.begin()}; i < num_docs; i++, wind_doc_it++) { + Sensors & sensors = sensors_vec[i]; + const std::string & timestamp = timestamp_vec[i]; + const bsoncxx::document::view wind_doc = *wind_doc_it; + for (bsoncxx::array::element wind_doc : wind_doc["windSensors"].get_array().value) { + Sensors::Wind * wind = sensors.add_wind_sensors(); + wind->set_speed(static_cast(wind_doc["speed"].get_double().value)); + wind->set_direction(static_cast(wind_doc["direction"].get_int32().value)); + } + expectEQ(sensors.wind_sensors().size(), NUM_WIND_SENSORS, "Size mismatch when reading batteries from DB"); + expectEQ(wind_doc["timestamp"].get_utf8().value.to_string(), timestamp, "Document timestamp mismatch"); + } + + // local path + mongocxx::collection path_coll = db[COLLECTION_LOCAL_PATH]; + mongocxx::cursor local_path_docs = path_coll.find({}, opts); + expectEQ( + static_cast(path_coll.count_documents({})), num_docs, + "Error: TestDB should only have " + std::to_string(num_docs) + " documents per collection"); + + for (auto [i, path_doc_it] = std::tuple{size_t{0}, local_path_docs.begin()}; i < num_docs; i++, path_doc_it++) { + Sensors & sensors = sensors_vec[i]; + const std::string & timestamp = timestamp_vec[i]; + const bsoncxx::document::view path_doc = *path_doc_it; + for (bsoncxx::array::element path_doc : path_doc["waypoints"].get_array().value) { + Polaris::Waypoint * path = sensors.mutable_local_path_data()->add_waypoints(); + path->set_latitude(static_cast(path_doc["latitude"].get_double().value)); + path->set_longitude(static_cast(path_doc["longitude"].get_double().value)); + } + expectEQ( + sensors.local_path_data().waypoints_size(), NUM_PATH_WAYPOINTS, + "Size mismatch when reading path waypoints from DB"); + expectEQ(path_doc["timestamp"].get_utf8().value.to_string(), timestamp, "Document timestamp mismatch"); + } + + return {sensors_vec, timestamp_vec}; +} + +UtilDB::UtilDB(const std::string & db_name, const std::string & mongodb_conn_str, std::shared_ptr rng) +: SailbotDB(db_name, mongodb_conn_str), rng_(rng) +{ +} + +void UtilDB::genRandGpsData(Sensors::Gps & gps_data) +{ + std::uniform_real_distribution lat_dist(LAT_LBND, LAT_UBND); + std::uniform_real_distribution lon_dist(LON_LBND, LON_UBND); + std::uniform_real_distribution speed_dist(SPEED_LBND, SPEED_UBND); + std::uniform_real_distribution heading_dist(HEADING_LBND, HEADING_UBND); + gps_data.set_latitude(lat_dist(*rng_)); + gps_data.set_longitude(lon_dist(*rng_)); + gps_data.set_speed(speed_dist(*rng_)); + gps_data.set_heading(heading_dist(*rng_)); +} + +void UtilDB::genRandAisData(Sensors::Ais & ais_ship) +{ + std::uniform_int_distribution id_dist(0, UINT32_MAX); + std::uniform_real_distribution lat_dist(LAT_LBND, LAT_UBND); + std::uniform_real_distribution lon_dist(LON_LBND, LON_UBND); + std::uniform_real_distribution speed_dist(SPEED_LBND, SPEED_UBND); + std::uniform_real_distribution heading_dist(HEADING_LBND, HEADING_UBND); + std::uniform_real_distribution rot_dist(ROT_LBND, ROT_UBND); + std::uniform_real_distribution width_dist(SHIP_DIMENSION_LBND, SHIP_DIMENSION_UBND); + std::uniform_real_distribution length_dist(SHIP_DIMENSION_LBND, SHIP_DIMENSION_UBND); + + ais_ship.set_id(id_dist(*rng_)); + ais_ship.set_latitude(lat_dist(*rng_)); + ais_ship.set_longitude(lon_dist(*rng_)); + ais_ship.set_sog(speed_dist(*rng_)); + ais_ship.set_cog(heading_dist(*rng_)); + ais_ship.set_rot(rot_dist(*rng_)); + ais_ship.set_width(width_dist(*rng_)); + ais_ship.set_length(length_dist(*rng_)); +} + +void UtilDB::genRandGenericSensorData(Sensors::Generic & generic_sensor) +{ + std::uniform_int_distribution id_generic(0, UINT8_MAX); + std::uniform_int_distribution data_generic(0, UINT64_MAX); + + generic_sensor.set_id(id_generic(*rng_)); + generic_sensor.set_data(data_generic(*rng_)); +} + +void UtilDB::genRandBatteryData(Sensors::Battery & battery) +{ + std::uniform_real_distribution voltage_battery(BATT_VOLT_LBND, BATT_VOLT_UBND); + std::uniform_real_distribution current_battery(BATT_CURR_LBND, BATT_CURR_UBND); + + battery.set_voltage(voltage_battery(*rng_)); + battery.set_current(current_battery(*rng_)); +} + +void UtilDB::genRandWindData(Sensors::Wind & wind_data) +{ + std::uniform_real_distribution speed_wind(SPEED_LBND, SPEED_UBND); + std::uniform_int_distribution direction_wind(WIND_DIRECTION_LBND, WIND_DIRECTION_UBND); + + wind_data.set_speed(speed_wind(*rng_)); + wind_data.set_direction(direction_wind(*rng_)); +} + +void UtilDB::genRandPathData(Sensors::Path & path_data) +{ + std::uniform_real_distribution latitude_path(LAT_LBND, LAT_UBND); + std::uniform_real_distribution longitude_path(LON_LBND, LON_UBND); + + for (int i = 0; i < NUM_PATH_WAYPOINTS; i++) { + Polaris::Waypoint * waypoint = path_data.add_waypoints(); + waypoint->set_latitude(latitude_path(*rng_)); + waypoint->set_longitude(longitude_path(*rng_)); + } +} diff --git a/src/network_systems/lib/sailbot_db/test/test_sailbot_db.cpp b/src/network_systems/lib/sailbot_db/test/test_sailbot_db.cpp new file mode 100644 index 000000000..f2fa59ee1 --- /dev/null +++ b/src/network_systems/lib/sailbot_db/test/test_sailbot_db.cpp @@ -0,0 +1,40 @@ +#include + +#include "sailbot_db.h" +#include "util_db.h" + +using Polaris::Sensors; + +static std::random_device g_rd = std::random_device(); // random number sampler +static uint32_t g_rand_seed = g_rd(); // seed used for random number generation +static std::mt19937 g_mt(g_rand_seed); // initialize random number generator with seed +static UtilDB g_test_db("test", MONGODB_CONN_STR, std::make_shared(g_mt)); +class TestSailbotDB : public ::testing::Test +{ +protected: + TestSailbotDB() { g_test_db.cleanDB(); } + ~TestSailbotDB() {} +}; + +/** + * @brief Check that MongoDB is running + */ +TEST_F(TestSailbotDB, TestConnection) +{ + ASSERT_TRUE(g_test_db.testConnection()) << "MongoDB not running - remember to connect!"; +} + +/** + * @brief Write random sensor data to the TestDB - read and verify said data + */ +TEST_F(TestSailbotDB, TestStoreSensors) +{ + SCOPED_TRACE("Seed: " + std::to_string(g_rand_seed)); // Print seed on any failure + auto [rand_sensors, rand_info] = g_test_db.genRandData(UtilDB::getTimestamp()); + ASSERT_TRUE(g_test_db.storeNewSensors(rand_sensors, rand_info)); + + std::array expected_sensors = {rand_sensors}; + std::array expected_info = {rand_info}; + + EXPECT_TRUE(g_test_db.verifyDBWrite(expected_sensors, expected_info)); +} diff --git a/src/network_systems/lib/utils/utils.h b/src/network_systems/lib/utils/utils.h new file mode 100644 index 000000000..6239101c2 --- /dev/null +++ b/src/network_systems/lib/utils/utils.h @@ -0,0 +1,153 @@ +#pragma once + +#include +#include +#include +#include + +// Define a concept for arithmetic types +template +concept arithmetic = std::integral or std::floating_point; +template +concept not_float = not std::floating_point; + +namespace utils +{ + +/** + * @brief Check if an input value is within its bounds + * + * @tparam T arithmetic type to check + * @param val value to check + * @param lbnd lower bound + * @param ubnd upper bound + * @return error string if out of bounds, std::nullopt if okay + */ +template +std::optional isOutOfBounds(T val, T lbnd, T ubnd) +{ + if (val < lbnd || val > ubnd) { + std::stringstream ss; + ss << typeid(T).name() << "(" << val << ") is out of bounds" << std::endl + << "lbnd: " << lbnd << std::endl + << "ubnd: " << ubnd << std::endl; + return ss.str(); + } + return std::nullopt; +} + +/** + * @brief Calculate floating point equality using the GoogleTest default definition + * http://google.github.io/googletest/reference/assertions.html#floating-point + * + * @tparam T A floating point type (float, double, etc) + * @param to_check Value to compare to expected + * @param expected Expected value + * @param err_msg String that gets printed to stderr on failure + * @return true if "to_check" is close enough to "expected", false otherwise + */ +template +bool isFloatEQ(T to_check, T expected, const std::string & err_msg) +{ + constexpr int ALLOWED_ULP_DIFF = 4; + + int diff = boost::math::float_distance(to_check, expected); + if (std::abs(diff) <= ALLOWED_ULP_DIFF) { + return true; + } + if (!err_msg.empty()) { + std::cerr << err_msg << std::endl; + } + return false; +} + +/** + * @brief Calls default isFloatEq(T, T, string) with an empty error string + * + */ +template +bool isFloatEQ(T to_check, T expected) +{ + return isFloatEQ(to_check, expected, ""); +} + +/** + * @brief Check if two non-floating point values are equal and output an error on mismatch + * + * @tparam T non-floating point type + * @param rcvd received value + * @param expected expected value + * @param err_msg error string + * @return true if rcvd and expected match + * @return false otherwise + */ +template +bool checkEQ(T rcvd, T expected, const std::string & err_msg) +{ + if (rcvd != expected) { + std::cerr << "Expected: " << expected << " but received: " << rcvd << std::endl; + std::cerr << err_msg << std::endl; + return false; + } + return true; +}; + +/** + * @brief Check if two floating point values are equal using isFloatEq() and output an error on mismatch + * + * @tparam T floating point values (float, double, etc...) + * @param rcvd received value + * @param expected expected value + * @param err_msg error string + * @return true if rcvd and expected match + * @return false otherwise + */ +template +bool checkEQ(T rcvd, T expected, const std::string & err_msg) +{ + std::stringstream ss; + ss << "Expected: " << expected << " but received: " << rcvd << "\n" << err_msg; + return static_cast(utils::isFloatEQ(rcvd, expected, ss.str())); +}; + +/** + * @brief Simple class to count number of failures + * + */ +class FailTracker +{ +public: + /** + * @brief Update the tracker + * + * @param was_success result of operation to check + */ + void track(bool was_success) + { + if (!was_success) { + fail_count_++; + } + } + + /** + * @brief Reset fail count + * + */ + void reset() { fail_count_ = 0; } + + /** + * @return true if any failures were tracked + * @return false otherwise + */ + bool failed() const { return fail_count_ != 0; } + + /** + * @return number of failures + */ + uint32_t failCount() const { return fail_count_; } + +private: + uint32_t fail_count_ = 0; +}; + +} // namespace utils diff --git a/src/network_systems/package.xml b/src/network_systems/package.xml new file mode 100755 index 000000000..c0c2c28fd --- /dev/null +++ b/src/network_systems/package.xml @@ -0,0 +1,20 @@ + + + + network_systems + 0.0.0 + UBC Sailbot's NET programs + Henry Huang + Apache License 2.0 + + ament_cmake + rclcpp + std_msgs + custom_interfaces + ros2launch + + + ament_cmake + + diff --git a/src/network_systems/projects/CMakeLists.txt b/src/network_systems/projects/CMakeLists.txt new file mode 100755 index 000000000..2f187bfef --- /dev/null +++ b/src/network_systems/projects/CMakeLists.txt @@ -0,0 +1,7 @@ +add_subdirectory(mock_ais) +add_subdirectory(can_transceiver) +add_subdirectory(example) +add_subdirectory(local_transceiver) +add_subdirectory(remote_transceiver) + +# add any additional project directories diff --git a/src/network_systems/projects/can_transceiver/CMakeLists.txt b/src/network_systems/projects/can_transceiver/CMakeLists.txt new file mode 100644 index 000000000..995d20b2f --- /dev/null +++ b/src/network_systems/projects/can_transceiver/CMakeLists.txt @@ -0,0 +1,28 @@ +set(module can_transceiver) + +set(link_libs +) + +set(inc_dirs +) + +set(compile_defs +) + +set(srcs + ${CMAKE_CURRENT_LIST_DIR}/src/can_transceiver.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/can_frame_parser.cpp +) + +# Create module ROS executable +set(bin_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/src/can_transceiver_ros_intf.cpp +) +make_exe(${module} "${bin_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +set(test_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/test/test_can_transceiver.cpp +) +make_unit_test(${module} "${test_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") diff --git a/src/network_systems/projects/can_transceiver/diagrams/common_sequence.puml b/src/network_systems/projects/can_transceiver/diagrams/common_sequence.puml new file mode 100644 index 000000000..6f19bc2b1 --- /dev/null +++ b/src/network_systems/projects/can_transceiver/diagrams/common_sequence.puml @@ -0,0 +1,37 @@ +@startuml common_sequence +title CAN Transceiver Common Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") + +autonumber + +!startsub PARTICIPANTS +box "Common Sequence" +participant CanTransceiver as can +participant CanTransceiverRosIntf as ros_intf +end box +== Inbound == +!endsub PARTICIPANTS + +-> can : Inbound Sensor(s) [CAN] + +!startsub SEQUENCE + +activate can +can -> can : Convert from CAN to ROS +can -> can : Filter Sensor(s) +can -> ros_intf --++ : Transmit Sensor(s) [ROS] +ros_intf -> : Publish sensor(s) to Software ROS Network +deactivate ros_intf + +== Outbound == + +ros_intf <- ++ : Command(s) from Software ROS Network (ex. Desired Heading) +can <- ros_intf --++ : Transmit Command(s) [ROS] +can -> can : Convert Command(s) from ROS to CAN +!endsub SEQUENCE + +<- can : Outbound Command(s) [CAN] +deactivate can + +@enduml diff --git a/src/network_systems/projects/can_transceiver/diagrams/deployment_sequence.puml b/src/network_systems/projects/can_transceiver/diagrams/deployment_sequence.puml new file mode 100644 index 000000000..b1e440aee --- /dev/null +++ b/src/network_systems/projects/can_transceiver/diagrams/deployment_sequence.puml @@ -0,0 +1,16 @@ +@startuml deployment_sequence +title CAN Transceiver Deployment Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") + +autonumber + +!includesub common_sequence.puml!PARTICIPANTS + +-> can : Sensors from ELEC [CAN] + +!includesub common_sequence.puml!SEQUENCE + +<- can -- : Transmit Command(s) to ELEC [CAN] + +@enduml diff --git a/src/network_systems/projects/can_transceiver/diagrams/simulation_sequence.puml b/src/network_systems/projects/can_transceiver/diagrams/simulation_sequence.puml new file mode 100644 index 000000000..3b4400fd3 --- /dev/null +++ b/src/network_systems/projects/can_transceiver/diagrams/simulation_sequence.puml @@ -0,0 +1,29 @@ +@startuml simulation_sequence +title CAN Transceiver Simulation Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") + +autonumber + +box "Simulation Sequence" +participant CanSimIntf as sim_intf +participant CanSimTransceiver as sim_t +end box + +!includesub common_sequence.puml!PARTICIPANTS + +-> sim_intf ++ : Mock Sensors from Simulator [ROS] +sim_intf -> sim_t --++ : Process Mock Sensors [ROS] +sim_t -> sim_t : Convert ROS sensor object to CAN +sim_t --> can -- : Call new Sensors API [CAN] +deactivate sim_intf +note right sim_t : API call should be async to replicate deployment behavior. + +!includesub common_sequence.puml!SEQUENCE + +sim_t <-- can --++ : Call Write to CAN API +note left can : API call should be async to replicate deployment behavior. +sim_t -> sim_t : Convert Command(s) from CAN to ROS +sim_intf <- sim_t --++ : Transmit Command(s) [ROS] +activate sim_intf +<- sim_intf -- : Publish Command(s) to Simulator [ROS] diff --git a/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h b/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h new file mode 100644 index 000000000..9b6c935e5 --- /dev/null +++ b/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h @@ -0,0 +1,227 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +// CAN frame definitions from: https://ubcsailbot.atlassian.net/wiki/spaces/prjt22/pages/1827176527/CAN+Frames +namespace CAN_FP +{ + +using CanFrame = struct canfd_frame; +using RawDataBuf = std::array; +namespace msg = custom_interfaces::msg; + +/** + * @brief IDs of CAN frames relevant to the Software team + * + */ +enum class CanId : canid_t { + RESERVED = 0x00, + BMS_P_DATA_FRAME_1 = 0x31, + BMS_P_DATA_FRAME_2 = 0x32, + SAIL_WSM_CMD_FRAME_1 = 0x60, + SAIL_WSM_CMD_FRAME_2 = 0x61, + SAIL_ENCD_DATA_FRAME = 0x62, + SAIL_WSM_DATA_FRAME_1 = 0x63, + SAIL_WSM_DATA_FRAME_2 = 0x64, + SAIL_WIND_DATA_FRAME_1 = 0x65, + SAIL_NAV_CMD_FRAME = 0x66, + RUDR_CMD_FRAME = 0x70, + RUDR_DATA_FRAME_1 = 0x71, + RUDR_DATA_FRAME_2 = 0x72, + PATH_GPS_DATA_FRAME_1 = 0x80, + PATH_GPS_DATA_FRAME_2 = 0x81, + PATH_GPS_DATA_FRAME_3 = 0x82, + PATH_GPS_DATA_FRAME_4 = 0x83, + PATH_WIND_DATA_FRAME = 0x84, +}; + +/** + * @brief Map the CanId enum to a description + * + */ +static const std::map CAN_DESC{ + {CanId::RESERVED, "RESERVED"}, + {CanId::BMS_P_DATA_FRAME_1, "BMS_P_DATA_FRAME_1 (Battery 1 data)"}, + {CanId::BMS_P_DATA_FRAME_2, "BMS_P_DATA_FRAME_2 (Battery 2 data)"}, + {CanId::SAIL_WSM_CMD_FRAME_1, "SAIL_WSM_CMD_FRAME_1 (Main sail command)"}, + {CanId::SAIL_WSM_CMD_FRAME_2, "SAIL_WSM_CMD_FRAME_2 (Jib Sail command)"}, + {CanId::SAIL_ENCD_DATA_FRAME, "SAIL_ENCD_DATA_FRAME (Sail encoder data)"}, + {CanId::SAIL_WSM_DATA_FRAME_1, "SAIL_WSM_DATA_FRAME_1 (Main sail data)"}, + {CanId::SAIL_WSM_DATA_FRAME_2, "SAIL_WSM_DATA_FRAME_2 (Jib sail data)"}, + {CanId::SAIL_WIND_DATA_FRAME_1, "SAIL_WIND_DATA_FRAME_1 (Mast wind sensor)"}, + {CanId::SAIL_NAV_CMD_FRAME, "SAIL_NAV_CMD_FRAME (Nav light commands)"}, + {CanId::RUDR_CMD_FRAME, "RUDR_CMD_FRAME (Rudder commands [BOTH RUDDERS])"}, + {CanId::RUDR_DATA_FRAME_1, "RUDR_DATA_FRAME_1 (Port rudder data)"}, + {CanId::RUDR_DATA_FRAME_2, "RUDR_DATA_FRAME_2 (Starboard rudder data)"}, + {CanId::PATH_GPS_DATA_FRAME_1, "PATH_GPS_DATA_FRAME_1 (GPS latitude)"}, + {CanId::PATH_GPS_DATA_FRAME_2, "PATH_GPS_DATA_FRAME_2 (GPS longitude)"}, + {CanId::PATH_GPS_DATA_FRAME_3, "PATH_GPS_DATA_FRAME_3 (GPS other data)"}, + {CanId::PATH_GPS_DATA_FRAME_4, "PATH_GPS_DATA_FRAME_4 (GPS time reporting [ex. day of the year])"}, + {CanId::PATH_WIND_DATA_FRAME, "PATH_WIND_DATA_FRAME (Hull wind sensor)"}}; + +/** + * @brief Custom exception for when an attempt is made to construct a CAN object with a mismatched ID + * + */ +class CanIdMismatchException : public std::exception +{ +public: + /** + * @brief Instantiate the CanIdMismatchException + * + * @param valid_ids Expected IDs + * @param received The invalid ID that was received + */ + CanIdMismatchException(std::span valid_ids, CanId received); + + using std::exception::what; // Needed to resolve virtual function overload error + /** + * @brief Return the exception message + * + * @return exception message + */ + const char * what(); + +private: + std::string msg_; // exception message +}; + +/** + * @brief Abstract class that represents a generic dataframe. Not meant to be instantiated + * on its own, and must be instantiated with a derived class + * + */ +class BaseFrame +{ +public: + const CanId id_; + const uint8_t can_byte_dlen_; // Number of bytes of data used in the Linux CAN representation + + /** + * @brief Override the << operator for printing + * + * @param os output stream (typically std::cout) + * @param can BaseFrame instance to print + * @return stream to print + */ + friend std::ostream & operator<<(std::ostream & os, const BaseFrame & can); + +protected: + /** + * @brief Derived classes can instantiate a base frame using an CanId and a data length + * + * @param id CanId of the fraeme + * @param can_byte_dlen Number of bytes used in the Linux CAN representation + */ + BaseFrame(CanId id, uint8_t can_byte_dlen); + + /** + * @brief Derived classes can instantiate a base frame and check if the id is vaild + * + * @param valid_ids a span of valid CanIds + * @param id the id to check + * @param can_byte_dlen_ Number of bytes used in the Linux CAN representatio nof the dataframe + */ + BaseFrame(std::span valid_ids, CanId id, uint8_t can_byte_dlen_); + + /** + * @return The Linux CanFrame representation of the frame + */ + virtual CanFrame toLinuxCan() const; + + /** + * @return A string that can be printed or logged for debugging + */ + virtual std::string debugStr() const; +}; + +/** + * @brief A battery class derived from the BaseFrame. Represents battery data. + * + */ +class Battery final : public BaseFrame +{ +public: + // Valid CanIds that a Battery object can have + static constexpr std::array BATTERY_IDS = {CanId::BMS_P_DATA_FRAME_1, CanId::BMS_P_DATA_FRAME_2}; + static constexpr uint8_t CAN_BYTE_DLEN_ = 8; + static constexpr uint8_t BYTE_OFF_VOLT = 0; + static constexpr uint8_t BYTE_OFF_CURR = 2; + static constexpr uint8_t BYTE_OFF_MAX_VOLT = 4; + static constexpr uint8_t BYTE_OFF_MIN_VOLT = 6; + + /** + * @brief Explicitly deleted no-argument constructor + * + */ + Battery() = delete; + + /** + * @brief Construct a Battery object from a Linux CanFrame representation + * + * @param cf Linux CanFrame + */ + explicit Battery(const CanFrame & cf); + + /** + * @brief Construct a Battery object from a custom_interfaces ROS msg representation + * + * @param ros_bat custom_interfaces representation of a Battery + * @param id CanId of the battery (use the rosIdxToCanId() method if unknown) + */ + explicit Battery(msg::HelperBattery ros_bat, CanId id); + + /** + * @return the custom_interfaces ROS representation of the Battery object + */ + msg::HelperBattery toRosMsg() const; + + /** + * @return the Linux CanFrame representation of the Battery object + */ + CanFrame toLinuxCan() const override; + + /** + * @return A string that can be printed or logged to debug a Battery object + */ + std::string debugStr() const override; + + /** + * @brief Factory method to convert the index of a battery in the custom_interfaces ROS representation + * into a CanId if valid. + * + * @param bat_idx idx of the battery in a custom_interfaces::msg::Batteries array + * @return CanId if valid, std::nullopt if invalid + */ + static std::optional rosIdxToCanId(size_t bat_idx); + +private: + /** + * @brief Private helper constructor for Battery objects + * + * @param id CanId of the battery + */ + explicit Battery(CanId id); + + /** + * @brief Check if the assigned fields after constructing a Battery object are within bounds. + * @throws std::out_of_range if any assigned fields are outside of expected bounds + */ + void checkBounds() const; + + // Note: Each BMS battery is comprised of multiple battery cells + float volt_; // Average voltage of cells in the battery + float curr_; // Current - positive means charging and negative means discharging (powering the boat) + float volt_max_; // Maximum voltage of cells in the battery pack (unused) + float volt_min_; // Minimum voltage of cells in the battery pack (unused) +}; + +} // namespace CAN_FP diff --git a/src/network_systems/projects/can_transceiver/inc/can_transceiver.h b/src/network_systems/projects/can_transceiver/inc/can_transceiver.h new file mode 100644 index 000000000..8053dff05 --- /dev/null +++ b/src/network_systems/projects/can_transceiver/inc/can_transceiver.h @@ -0,0 +1,100 @@ +#pragma once + +#include + +#include +#include + +#include "can_frame_parser.h" + +/** + * @brief CAN Transceiver Class + * Handles transmission and reception of data to and from the hardware/simulator + * + */ +class CanTransceiver +{ +public: + /** + * @brief Construct a new Can Transceiver and connect it to the default CAN interface (can0) + * @note Can only be used in deployment + * + */ + CanTransceiver(); + + /** + * @brief Construct a new Can Transceiver and connect it to an existing and open file descriptor + * @note Can only be used if simulating the CAN bus + * + * @param fd + */ + explicit CanTransceiver(int fd); + + /** + * @brief Close the opened CAN port and kill the receive() thread + * + */ + ~CanTransceiver(); + + /** + * @brief Send a CAN frame to the CAN port + * + * @param frame CAN frame to send + */ + void send(const CAN_FP::CanFrame & frame) const; + + /** + * @brief Register a CanId -> CallbackFunc mapping to be called when the CanId is read from the CAN port + * + * @param cb_kvp pair of CanId and the callback function to associate with it + */ + void registerCanCb(std::pair> cb_kvp); + + /** + * @brief Register multiple CanId -> CallbackFunc mappings. See registerCanCb(). + * + * @param cb_kvps initializer list of cb_kvp pairs + */ + void registerCanCbs( + const std::initializer_list>> & cb_kvps); + +private: + // CAN socket this instance is attached to (can be a normal file descriptor when simulating) + int sock_desc_; + // flag to indicate whether the connected CAN socket is a simulated socket or a real socket + bool is_can_simulated_; + // Mutex to protect the CAN port from simultaneous reads and writes + // mutable keyword required for std::lock_guard + mutable std::mutex can_mtx_; + + // Thread that listens to CAN + std::thread receive_thread_; + // Flag to tell the receive_thread_ to stop + bool shutdown_flag_ = false; + + // For each CanId key in this map, if the CanId is read from the CAN bus, then the associated callback function + // is invoked + std::map> read_callbacks_; + + /** + * @brief Retrieve latest incoming CAN frame from hardware and process it + * Infinitely loops on a read() syscall, so needs to be run in another thread + * Can be shutdown by setting shutdown_flag_ to true + */ + void receive(); + + /** + * @brief Call on successfully reading a new CAN data frame from hardware/simulator + * + * @param frame received CAN data frame + */ + void onNewCanData(const CAN_FP::CanFrame & frame) const; +}; + +/** + * @brief Create a mock CAN socket descriptor for simulation purposes + * + * @param template_str File path ending in XXXXXX. Ex: "/tmp/fileXXXXXX" + * @return int opened file descriptor + */ +int mockCanFd(std::string template_str); diff --git a/src/network_systems/projects/can_transceiver/src/can_frame_parser.cpp b/src/network_systems/projects/can_transceiver/src/can_frame_parser.cpp new file mode 100644 index 000000000..d32afaf5f --- /dev/null +++ b/src/network_systems/projects/can_transceiver/src/can_frame_parser.cpp @@ -0,0 +1,170 @@ +#include "can_frame_parser.h" + +#include + +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "utils/utils.h" + +namespace CAN_FP +{ + +namespace +{ +/** + * @brief Convert a CanId to its string representation + * + * @param id CanId to convert + * @return std::string + */ +std::string CanIdToStr(CanId id) { return std::to_string(static_cast(id)); } + +/** + * @brief Get a debug string for a CanId + * + * @param id CanId to target + * @return std::string + */ +std::string CanDebugStr(CanId id) { return CanIdToStr(id) + ": " + CAN_DESC.at(id); } +} // namespace + +// CanIdMismatchException START + +CanIdMismatchException::CanIdMismatchException(std::span valid_ids, CanId received) +{ + std::string build_msg = "Mismatch between received ID: (" + CanIdToStr(received) + ") and valid IDs: \n"; + for (const CanId & id : valid_ids) { + build_msg += CanDebugStr(id) + "\n"; + } + msg_ = build_msg; +} + +const char * CanIdMismatchException::what() { return msg_.c_str(); } + +// CanIdMismatchException END +// BaseFrame START +// BaseFrame public START + +std::ostream & operator<<(std::ostream & os, const BaseFrame & can) { return os << can.debugStr(); } + +// BaseFrame public END +// BaseFrame protected START + +BaseFrame::BaseFrame(CanId id, uint8_t can_byte_dlen) : id_(id), can_byte_dlen_(can_byte_dlen) {} + +BaseFrame::BaseFrame(std::span valid_ids, CanId id, uint8_t can_byte_dlen) : BaseFrame(id, can_byte_dlen) +{ + bool valid = std::any_of(valid_ids.begin(), valid_ids.end(), [&id](CanId valid_id) { return id == valid_id; }); + if (!valid) { + throw CanIdMismatchException(valid_ids, id); + } +} + +std::string BaseFrame::debugStr() const { return CanDebugStr(id_); } + +CanFrame BaseFrame::toLinuxCan() const { return CanFrame{.can_id = static_cast(id_), .len = can_byte_dlen_}; } + +// BaseFrame protected END +// BaseFrame END +// Battery START +// Battery public START + +Battery::Battery(const CanFrame & cf) : Battery(static_cast(cf.can_id)) +{ + int16_t raw_volt; + int16_t raw_curr; + int16_t raw_max_volt; + int16_t raw_min_volt; + + std::memcpy(&raw_volt, cf.data + BYTE_OFF_VOLT, sizeof(int16_t)); + std::memcpy(&raw_curr, cf.data + BYTE_OFF_CURR, sizeof(int16_t)); + std::memcpy(&raw_max_volt, cf.data + BYTE_OFF_MAX_VOLT, sizeof(int16_t)); + std::memcpy(&raw_min_volt, cf.data + BYTE_OFF_MIN_VOLT, sizeof(int16_t)); + + volt_ = static_cast(raw_volt) / 100; // NOLINT(readability-magic-numbers) + curr_ = static_cast(raw_curr) / 100; // NOLINT(readability-magic-numbers) + + // TODO(hhenry01): Max and min are dodgy... it doesn't make sense to not divide them by 100 - confirm with ELEC + volt_max_ = static_cast(raw_max_volt); + volt_min_ = static_cast(raw_min_volt); + + checkBounds(); +} + +Battery::Battery(msg::HelperBattery ros_bat, CanId id) +: BaseFrame(id, CAN_BYTE_DLEN_), volt_(ros_bat.voltage), curr_(ros_bat.current), volt_max_(0.0), volt_min_(0.0) +{ + checkBounds(); +} + +msg::HelperBattery Battery::toRosMsg() const +{ + msg::HelperBattery msg; + msg.set__voltage(volt_); + msg.set__current(curr_); + return msg; +} + +CanFrame Battery::toLinuxCan() const +{ + int16_t raw_volt = static_cast(volt_ * 100); // NOLINT(readability-magic-numbers) + int16_t raw_curr = static_cast(curr_ * 100); // NOLINT(readability-magic-numbers) + // TODO(hhenry01): Max and min are dodgy... it doesn't make sense to not multiply them by 100 - confirm with ELEC + int16_t raw_max_volt = static_cast(volt_max_); + int16_t raw_min_volt = static_cast(volt_max_); + + CanFrame cf = BaseFrame::toLinuxCan(); + std::memcpy(cf.data + BYTE_OFF_VOLT, &raw_volt, sizeof(int16_t)); + std::memcpy(cf.data + BYTE_OFF_CURR, &raw_curr, sizeof(int16_t)); + std::memcpy(cf.data + BYTE_OFF_MAX_VOLT, &raw_max_volt, sizeof(int16_t)); + std::memcpy(cf.data + BYTE_OFF_MIN_VOLT, &raw_min_volt, sizeof(int16_t)); + + return cf; +} + +std::string Battery::debugStr() const +{ + std::stringstream ss; + ss << BaseFrame::debugStr() << "\n" + << "Voltage (V): " << volt_ << "\n" + << "Current (A): " << curr_ << "\n" + << "Max voltage (V): " << volt_max_ << "\n" + << "Min voltage (V): " << volt_min_; + return ss.str(); +} + +std::optional Battery::rosIdxToCanId(size_t bat_idx) +{ + if (bat_idx < BATTERY_IDS.size()) { + return BATTERY_IDS[bat_idx]; + } + return std::nullopt; +} + +// Battery public END +// Battery private START + +Battery::Battery(CanId id) : BaseFrame(std::span{BATTERY_IDS}, id, CAN_BYTE_DLEN_) {} + +void Battery::checkBounds() const +{ + auto err = utils::isOutOfBounds(volt_, BATT_VOLT_LBND, BATT_VOLT_UBND); + if (err) { + std::string err_msg = err.value(); + throw std::out_of_range("Battery voltage is out of bounds!\n" + debugStr() + "\n" + err_msg); + } + err = utils::isOutOfBounds(curr_, BATT_CURR_LBND, BATT_CURR_UBND); + if (err) { + std::string err_msg = err.value(); + throw std::out_of_range("Battery current is out of bounds!\n" + debugStr() + "\n" + err_msg); + } +} + +// Battery private END +// Battery END + +} // namespace CAN_FP diff --git a/src/network_systems/projects/can_transceiver/src/can_transceiver.cpp b/src/network_systems/projects/can_transceiver/src/can_transceiver.cpp new file mode 100644 index 000000000..730602281 --- /dev/null +++ b/src/network_systems/projects/can_transceiver/src/can_transceiver.cpp @@ -0,0 +1,142 @@ +#include "can_transceiver.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "can_frame_parser.h" + +using IFreq = struct ifreq; +using SockAddr = struct sockaddr; +using SockAddrCan = struct sockaddr_can; + +using CAN_FP::CanFrame; +using CAN_FP::CanId; + +void CanTransceiver::onNewCanData(const CanFrame & frame) const +{ + CanId id{frame.can_id}; + if (read_callbacks_.contains(id)) { + read_callbacks_.at(id)(frame); // invoke the callback function mapped to id + } +} + +void CanTransceiver::registerCanCb(const std::pair> cb_kvp) +{ + auto [key, cb] = cb_kvp; + read_callbacks_[key] = cb; +} + +void CanTransceiver::registerCanCbs( + const std::initializer_list>> & cb_kvps) +{ + for (const auto & cb_kvp : cb_kvps) { + registerCanCb(cb_kvp); + } +} + +CanTransceiver::CanTransceiver() : is_can_simulated_(false) +{ + // See: https://www.kernel.org/doc/html/next/networking/can.html#how-to-use-socketcan + static const char * CAN_INST = "can0"; + + // Everything between this comment and the initiation of the receive thread is pretty much + // magic from the socketcan documentation + + if ((sock_desc_ = socket(PF_CAN, SOCK_RAW, CAN_RAW)) < 0) { + std::string err_msg = "Failed to open CAN socket with error: " + std::to_string(errno) + ": " + + strerror(errno); // NOLINT(concurrency-mt-unsafe) + throw std::runtime_error(err_msg); + } + + IFreq ifr; + SockAddrCan addr; + + strncpy(ifr.ifr_name, CAN_INST, IFNAMSIZ); + ioctl(sock_desc_, SIOCGIFINDEX, &ifr); + + addr.can_family = AF_CAN; + addr.can_ifindex = ifr.ifr_ifindex; + + if (bind(sock_desc_, reinterpret_cast(&addr), sizeof(addr)) < 0) { + std::string err_msg = "Failed to bind CAN socket with error: " + std::to_string(errno) + ": " + + strerror(errno); // NOLINT(concurrency-mt-unsafe) + + throw std::runtime_error(err_msg); + } + + receive_thread_ = std::thread(&CanTransceiver::receive, this); +} + +CanTransceiver::CanTransceiver(int fd) : sock_desc_(fd), is_can_simulated_(true) +{ + receive_thread_ = std::thread(&CanTransceiver::receive, this); +} + +CanTransceiver::~CanTransceiver() +{ + shutdown_flag_ = true; + receive_thread_.join(); + close(sock_desc_); +} + +void CanTransceiver::receive() +{ + while (!shutdown_flag_) { + // make sure the lock is acquired and released INSIDE the loop, otherwise send() will never get the lock + std::lock_guard lock(can_mtx_); + CanFrame frame; + ssize_t bytes_read = read(sock_desc_, &frame, sizeof(CanFrame)); + if (bytes_read > 0) { + if (bytes_read != sizeof(CanFrame)) { + std::cerr << "CAN read error: read " << bytes_read << "B but CAN frames are expected to be " + << sizeof(CanFrame) << "B" << std::endl; + } else { + onNewCanData(frame); + } + } else if (bytes_read < 0) { + std::cerr << "CAN read error: " << errno << "(" << strerror(errno) // NOLINT(concurrency-mt-unsafe) + << ")" << std::endl; + } + } +} + +void CanTransceiver::send(const CanFrame & frame) const +{ + std::lock_guard lock(can_mtx_); + ssize_t bytes_written = write(sock_desc_, &frame, sizeof(CanFrame)); + if (bytes_written < 0) { + std::cerr << "CAN write error: " << errno << "(" << strerror(errno) // NOLINT(concurrency-mt-unsafe) + << ")" << std::endl; + } else { + if (bytes_written != sizeof(CanFrame)) { + std::cerr << "CAN write error: wrote " << bytes_written << "B but CAN frames are expected to be " + << sizeof(CanFrame) << "B" << std::endl; + } + if (is_can_simulated_) { + // Since we're writing to the same file we're reading from, we need to maintain the seek offset + // This is NOT necessary in deployment as we won't be using a file to mock it + lseek(sock_desc_, -static_cast(sizeof(CAN_FP::CanFrame)), SEEK_CUR); + } + } +} + +int mockCanFd(std::string tmp_file_template_str) +{ + // The vector is just super verbose and ugly std::string to cstr conversion done purely because + // the mkstemp() function is finicky with the string type it wants + std::vector tmp_file_template_cstr( + tmp_file_template_str.c_str(), tmp_file_template_str.c_str() + tmp_file_template_str.size() + 1); + int fd = mkstemp(tmp_file_template_cstr.data()); + if (fd == -1) { + std::string err_msg = "Failed to open mock CAN fd with error: " + std::to_string(errno) + "(" + + strerror(errno) + ")"; // NOLINT(concurrency-mt-unsafe) + throw std::runtime_error(err_msg); + } + return fd; +} diff --git a/src/network_systems/projects/can_transceiver/src/can_transceiver_ros_intf.cpp b/src/network_systems/projects/can_transceiver/src/can_transceiver_ros_intf.cpp new file mode 100644 index 000000000..35b96d6a5 --- /dev/null +++ b/src/network_systems/projects/can_transceiver/src/can_transceiver_ros_intf.cpp @@ -0,0 +1,195 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "can_frame_parser.h" +#include "can_transceiver.h" +#include "cmn_hdrs/ros_info.h" +#include "cmn_hdrs/shared_constants.h" + +constexpr int QUEUE_SIZE = 10; // Arbitrary number +constexpr auto TIMER_INTERVAL = std::chrono::milliseconds(500); + +namespace msg = custom_interfaces::msg; +using CAN_FP::CanFrame; +using CAN_FP::CanId; + +class CanTransceiverIntf : public rclcpp::Node +{ +public: + CanTransceiverIntf() : Node("can_transceiver_node") + { + this->declare_parameter("enabled", true); + + if (!this->get_parameter("enabled").as_bool()) { + RCLCPP_INFO(this->get_logger(), "CAN Transceiver is DISABLED"); + } else { + this->declare_parameter("mode", rclcpp::PARAMETER_STRING); + + rclcpp::Parameter mode_param = this->get_parameter("mode"); + std::string mode = mode_param.as_string(); + + if (mode == SYSTEM_MODE::PROD) { + RCLCPP_INFO(this->get_logger(), "Running CAN Transceiver in production mode"); + try { + can_trns_ = std::make_unique(); + } catch (std::runtime_error err) { + RCLCPP_ERROR(this->get_logger(), "%s", err.what()); + throw err; + } + } else if (mode == SYSTEM_MODE::DEV) { + RCLCPP_INFO(this->get_logger(), "Running CAN Transceiver in development mode with CAN Sim Intf"); + try { + sim_intf_fd_ = mockCanFd("/tmp/CanSimIntfXXXXXX"); + can_trns_ = std::make_unique(sim_intf_fd_); + } catch (std::runtime_error err) { + RCLCPP_ERROR(this->get_logger(), "%s", err.what()); + throw err; + } + } else { + std::string msg = "Error, invalid system mode" + mode; + RCLCPP_ERROR(this->get_logger(), "%s", msg.c_str()); + throw std::runtime_error(msg); + } + + ais_pub_ = this->create_publisher(AIS_SHIPS_TOPIC, QUEUE_SIZE); + batteries_pub_ = this->create_publisher(BATTERIES_TOPIC, QUEUE_SIZE); + + can_trns_->registerCanCbs({ + std::make_pair( + CanId::BMS_P_DATA_FRAME_1, + std::function([this](const CanFrame & frame) { publishBattery(frame); })), + std::make_pair( + CanId::BMS_P_DATA_FRAME_2, + std::function([this](const CanFrame & frame) { publishBattery(frame); })), + // TODO(lross03): Add remaining pairs + }); + + if (mode == SYSTEM_MODE::DEV) { // Initialize the CAN Sim Intf + mock_ais_sub_ = this->create_subscription( + MOCK_AIS_SHIPS_TOPIC, QUEUE_SIZE, + [this](msg::AISShips mock_ais_ships) { subMockAISCb(mock_ais_ships); }); + mock_gps_sub_ = this->create_subscription( + MOCK_GPS_TOPIC, QUEUE_SIZE, [this](msg::GPS mock_gps) { subMockGpsCb(mock_gps); }); + + // TODO(lross03): register a callback for CanSimToBoatSim + + timer_ = this->create_wall_timer(TIMER_INTERVAL, [this]() { + mockBatteriesCb(); + // Add any other necessary looping callbacks + }); + } + } + } + +private: + // pointer to the CAN Transceiver implementation + std::unique_ptr can_trns_; + + // Universal publishers and subscribers present in both deployment and simulation + rclcpp::Publisher::SharedPtr ais_pub_; + msg::AISShips ais_ships_; + rclcpp::Publisher::SharedPtr batteries_pub_; + msg::Batteries batteries_; + + // Simulation only publishers and subscribers + rclcpp::Subscription::SharedPtr mock_ais_sub_; + rclcpp::Subscription::SharedPtr mock_gps_sub_; + + // Timer for anything that just needs a repeatedly written value in simulation + rclcpp::TimerBase::SharedPtr timer_; + + // Mock CAN file descriptor for simulation + int sim_intf_fd_; + + /** + * @brief Publish AIS ships + * + */ + void publishAIS(const CanFrame & /**/) + { + //TODO(): Should be registered with CAN Transceiver once ELEC defines AIS frames + ais_pub_->publish(ais_ships_); + } + + /** + * @brief Publish a battery_frame + * Inteneded to be registered as a callback with the CAN Transceiver instance + * + * @param battery_frame battery CAN frame read from the CAN bus + */ + void publishBattery(const CanFrame & battery_frame) + { + CAN_FP::Battery bat(battery_frame); + + size_t idx; + for (size_t i = 0;; i++) { // idx WILL be in range (can_frame_parser constructors guarantee this) + if (bat.id_ == CAN_FP::Battery::BATTERY_IDS[i]) { + idx = i; + break; + } + } + msg::HelperBattery & bat_msg = batteries_.batteries[idx]; + bat_msg = bat.toRosMsg(); + batteries_pub_->publish(batteries_); + } + + /** + * @brief Mock AIS topic callback + * + * @param mock_ais_ships ais_ships received from the Mock AIS topic + */ + void subMockAISCb(msg::AISShips mock_ais_ships) + { + //TODO(): Should be routed through the CAN Transceiver once ELEC defines AIS frames + ais_ships_ = mock_ais_ships; + publishAIS(CanFrame{}); + } + + /** + * @brief Mock GPS topic callback + * + * @param mock_gps mock_gps received from the Mock GPS topic + */ + void subMockGpsCb(msg::GPS /*mock_gps*/) + { + // TODO(lross03): implement this + } + + /** + * @brief A mock batteries callback that just sends dummy (but valid) battery values to the simulation CAN intf + * Intended to be continuously invoked in a loop every once in a while + * + */ + void mockBatteriesCb() + { + msg::HelperBattery bat; + bat.set__voltage(BATT_VOLT_UBND); + bat.set__current(BATT_CURR_UBND); + for (size_t i = 0; i < NUM_BATTERIES; i++) { + auto optCanId = CAN_FP::Battery::rosIdxToCanId(i); + if (optCanId) { + can_trns_->send(CAN_FP::Battery(bat, optCanId.value()).toLinuxCan()); + } else { + RCLCPP_ERROR(this->get_logger(), "Failed to send mock battery of index %zu!", i); + } + } + } +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/network_systems/projects/can_transceiver/test/test_can_transceiver.cpp b/src/network_systems/projects/can_transceiver/test/test_can_transceiver.cpp new file mode 100644 index 000000000..1dee56f6c --- /dev/null +++ b/src/network_systems/projects/can_transceiver/test/test_can_transceiver.cpp @@ -0,0 +1,196 @@ +#include +#include +#include + +#include + +#include "can_frame_parser.h" +#include "can_transceiver.h" +#include "cmn_hdrs/shared_constants.h" + +namespace msg = custom_interfaces::msg; + +constexpr CAN_FP::RawDataBuf GARBAGE_DATA = []() constexpr +{ + CAN_FP::RawDataBuf buf; + for (uint8_t & entry : buf) { + entry = 0xFF; // NOLINT(readability-magic-numbers) + } + return buf; +} +(); + +/** + * @brief Test ROS<->CAN translations + * + */ +class TestCanFrameParser : public ::testing::Test +{ +protected: + TestCanFrameParser() {} + ~TestCanFrameParser() override {} +}; + +/** + * @brief Test ROS<->CAN Battery translations work as expected for valid input values + * + */ +TEST_F(TestCanFrameParser, BatteryTestValid) +{ + constexpr std::array expected_volts{12.5, 10.6}; + constexpr std::array expected_currs{2.5, -1.0}; // negative currents are valid + constexpr std::array expected_raw_volts{1250, 1060}; + constexpr std::array expected_raw_expected_currs{250, -100}; + + for (size_t i = 0; i < NUM_BATTERIES; i++) { + auto optId = CAN_FP::Battery::rosIdxToCanId(i); + + ASSERT_TRUE(optId.has_value()); + + CAN_FP::CanId id = optId.value(); + float expected_volt = expected_volts[i]; + float expected_curr = expected_currs[i]; + msg::HelperBattery msg; + msg.set__voltage(expected_volt); + msg.set__current(expected_curr); + CAN_FP::Battery bat_from_ros = CAN_FP::Battery(msg, id); + CAN_FP::CanFrame cf = bat_from_ros.toLinuxCan(); + + EXPECT_EQ(cf.can_id, static_cast(id)); + EXPECT_EQ(cf.len, CAN_FP::Battery::CAN_BYTE_DLEN_); + + int16_t raw_volt; + int16_t raw_curr; + std::memcpy(&raw_volt, cf.data + CAN_FP::Battery::BYTE_OFF_VOLT, sizeof(int16_t)); + std::memcpy(&raw_curr, cf.data + CAN_FP::Battery::BYTE_OFF_CURR, sizeof(int16_t)); + + EXPECT_EQ(raw_volt, expected_raw_volts[i]); + EXPECT_EQ(raw_curr, expected_raw_expected_currs[i]); + + CAN_FP::Battery bat_from_can = CAN_FP::Battery(cf); + + EXPECT_EQ(bat_from_can.id_, id); + + msg::HelperBattery msg_from_bat = bat_from_can.toRosMsg(); + + EXPECT_DOUBLE_EQ(msg_from_bat.voltage, expected_volt); + EXPECT_DOUBLE_EQ(msg_from_bat.current, expected_curr); + } +} + +/** + * @brief Test the behavior of the Battery class when given invalid input values + * + */ +TEST_F(TestCanFrameParser, TestBatteryInvalid) +{ + auto optId = CAN_FP::Battery::rosIdxToCanId(NUM_BATTERIES); + EXPECT_FALSE(optId.has_value()); + + CAN_FP::CanId invalid_id = CAN_FP::CanId::RESERVED; + + CAN_FP::CanFrame cf{.can_id = static_cast(invalid_id)}; + + EXPECT_THROW(CAN_FP::Battery tmp(cf), CAN_FP::CanIdMismatchException); + + std::vector invalid_volts{BATT_VOLT_LBND - 1, BATT_VOLT_UBND + 1}; + std::vector invalid_currs{BATT_CURR_LBND - 1, BATT_CURR_UBND + 1}; + + optId = CAN_FP::Battery::rosIdxToCanId(0); + ASSERT_TRUE(optId.has_value()); + + CAN_FP::CanId valid_id = optId.value(); + msg::HelperBattery msg; + + // Set a valid current for this portion + for (float invalid_volt : invalid_volts) { + msg.set__voltage(invalid_volt); + msg.set__current(BATT_CURR_LBND); + + EXPECT_THROW(CAN_FP::Battery tmp(msg, valid_id), std::out_of_range); + }; + + // Set a valid voltage for this portion + for (float invalid_curr : invalid_currs) { + msg.set__voltage(BATT_VOLT_LBND); + msg.set__current(invalid_curr); + + EXPECT_THROW(CAN_FP::Battery tmp(msg, valid_id), std::out_of_range); + }; + + cf.can_id = static_cast(CAN_FP::CanId::BMS_P_DATA_FRAME_1); + std::copy(std::begin(GARBAGE_DATA), std::end(GARBAGE_DATA), cf.data); + + EXPECT_THROW(CAN_FP::Battery tmp(cf), std::out_of_range); +} + +/** + * @brief Test CanTransceiver using a mock CAN descriptor + * + */ +class TestCanTransceiver : public ::testing::Test +{ +protected: + static constexpr auto SLEEP_TIME = std::chrono::milliseconds(20); + CanTransceiver * canbus_t_; + int fd_; + TestCanTransceiver() + { + fd_ = mockCanFd("/tmp/TestCanTransceiverXXXXXX"); + canbus_t_ = new CanTransceiver(fd_); + } + ~TestCanTransceiver() override { delete canbus_t_; } +}; + +/** + * @brief Test that callbacks can be properly registered and invoked on desired CanIds + * + */ +TEST_F(TestCanTransceiver, TestNewDataValid) +{ + volatile bool is_cb_called = false; + + std::function test_cb = [&is_cb_called](CAN_FP::CanFrame /*unused*/) { + is_cb_called = true; + }; + canbus_t_->registerCanCbs({{ + std::make_pair(CAN_FP::CanId::BMS_P_DATA_FRAME_1, test_cb), + }}); + + // just need a valid and matching ID for this test + CAN_FP::CanFrame dummy_frame{.can_id = static_cast(CAN_FP::CanId::BMS_P_DATA_FRAME_1)}; + + canbus_t_->send(dummy_frame); + + std::this_thread::sleep_for(SLEEP_TIME); + + EXPECT_TRUE(is_cb_called); +} + +/** + * @brief Test that the CanTransceiver ignores IDs that we don't register callbacks for + * + */ +TEST_F(TestCanTransceiver, TestNewDataIgnore) +{ + volatile bool is_cb_called = false; + + std::function test_cb = [&is_cb_called](CAN_FP::CanFrame /*unused*/) { + is_cb_called = true; + }; + canbus_t_->registerCanCbs({{ + std::make_pair(CAN_FP::CanId::BMS_P_DATA_FRAME_1, test_cb), + }}); + + // just need a valid and ignored ID for this test + CAN_FP::CanFrame dummy_frame{.can_id = static_cast(CAN_FP::CanId::RESERVED)}; + + canbus_t_->send(dummy_frame); + // Since we're writing to the same file we're reading from, we need to reset the seek offset + // This is NOT necessary in deployment as we won't be using a file to mock it + lseek(fd_, 0, SEEK_SET); + + std::this_thread::sleep_for(SLEEP_TIME); + + EXPECT_FALSE(is_cb_called); +} diff --git a/src/network_systems/projects/example/CMakeLists.txt b/src/network_systems/projects/example/CMakeLists.txt new file mode 100755 index 000000000..910bc8993 --- /dev/null +++ b/src/network_systems/projects/example/CMakeLists.txt @@ -0,0 +1,31 @@ +set(module example) + +# define external dependencies with link_libs and inc_dirs variables +set(link_libs +) + +set(inc_dirs +) + +set(compile_defs +) + +# Create module library +set(srcs + ${CMAKE_CURRENT_LIST_DIR}/src/cached_fib.cpp +) +make_lib(${module} "${srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +# Create module ROS executable +set(bin_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/src/cached_fib_ros_intf.cpp +) +make_exe(${module} "${bin_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +# Create unit test +set(test_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/test/test_cached_fib.cpp +) +make_unit_test(${module} "${test_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") diff --git a/src/network_systems/projects/example/README.md b/src/network_systems/projects/example/README.md new file mode 100755 index 000000000..5926c7332 --- /dev/null +++ b/src/network_systems/projects/example/README.md @@ -0,0 +1,13 @@ +# Example - Cached Fibonacci + +This is a simple example program to showcase and evaluate the workspace structure for Network Systems. + +Run it with `ros2 run network_systems example --ros-args -p enabled:=true`. + +Alternatively, using launch files run `ros2 launch network_systems main_launch.py config:=example/example_en.yaml`. + +Publish to the topic using: `ros2 topic pub cached_fib_in std_msgs/msg/UInt64 "{data: }" --once`. Run it +without the `--once` flag to repeatedly publish. + +The output will be shown in the terminal where the example is running. You can also see the output by running +`ros2 topic echo cached_fib_out` before sending the publish command. diff --git a/src/network_systems/projects/example/inc/cached_fib.h b/src/network_systems/projects/example/inc/cached_fib.h new file mode 100755 index 000000000..25aab1bfa --- /dev/null +++ b/src/network_systems/projects/example/inc/cached_fib.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +constexpr auto CACHED_FIB_TOPIC_IN = "cached_fib_in"; +constexpr auto CACHED_FIB_TOPIC_OUT = "cached_fib_out"; + +class CachedFib +{ +private: + std::vector cache_; + +public: + explicit CachedFib(std::size_t); + int getFib(std::size_t); +}; diff --git a/src/network_systems/projects/example/src/cached_fib.cpp b/src/network_systems/projects/example/src/cached_fib.cpp new file mode 100755 index 000000000..c00d7eb47 --- /dev/null +++ b/src/network_systems/projects/example/src/cached_fib.cpp @@ -0,0 +1,24 @@ +#include "cached_fib.h" + +#include +#include + +CachedFib::CachedFib(const std::size_t n) +{ + cache_.push_back(0); + cache_.push_back(1); + for (std::size_t i = 2; i < n; i++) { + cache_.push_back(cache_[i - 1] + cache_[i - 2]); + } +} + +int CachedFib::getFib(const std::size_t n) +{ + if (this->cache_.size() < n) { + for (std::size_t i = cache_.size(); i < n; i++) { + cache_.push_back(cache_[i - 1] + cache_[i - 2]); + } + } + std::cout << cache_[n - 1] << std::endl; + return cache_[n - 1]; +} diff --git a/src/network_systems/projects/example/src/cached_fib_ros_intf.cpp b/src/network_systems/projects/example/src/cached_fib_ros_intf.cpp new file mode 100644 index 000000000..4b90e6a39 --- /dev/null +++ b/src/network_systems/projects/example/src/cached_fib_ros_intf.cpp @@ -0,0 +1,57 @@ +// Include this module +#include "cached_fib.h" +// Include ROS headers +#include +#include + +namespace +{ +constexpr int ROS_Q_SIZE = 10; +constexpr int INIT_FIB_SIZE = 5; +} // namespace + +class CachedFibNode : public rclcpp::Node +{ +public: + explicit CachedFibNode(const std::size_t initSize) : Node("cached_fib_node"), c_fib_(initSize) + { + this->declare_parameter("enabled", false); + bool enabled = this->get_parameter("enabled").as_bool(); + if (enabled) { + RCLCPP_INFO(this->get_logger(), "Running example cached fib node"); + + // This registers a callback function that gets called whenever CACHED_FIB_TOPIC_IN gets updated + sub_ = this->create_subscription( + CACHED_FIB_TOPIC_IN, ROS_Q_SIZE, std::bind(&CachedFibNode::subCb, this, std::placeholders::_1)); + + pub_ = this->create_publisher(CACHED_FIB_TOPIC_OUT, ROS_Q_SIZE); + } + } + +private: + CachedFib c_fib_; + rclcpp::Subscription::SharedPtr sub_; + rclcpp::Publisher::SharedPtr pub_; + + /** + * @brief Callback function that runs whenever the subscribed topic is updated + * + * @param in_msg msg that was sent to the topic + */ + void subCb(const std_msgs::msg::UInt64::SharedPtr in_msg) + { + int fibNum = this->c_fib_.getFib(in_msg->data); + RCLCPP_INFO(this->get_logger(), "Fib num for '%lu' is '%d'", in_msg->data, fibNum); + std_msgs::msg::UInt64 out_msg{}; + out_msg.data = fibNum; + pub_->publish(out_msg); + } +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared(INIT_FIB_SIZE)); + rclcpp::shutdown(); + return 0; +} diff --git a/src/network_systems/projects/example/test/test_cached_fib.cpp b/src/network_systems/projects/example/test/test_cached_fib.cpp new file mode 100755 index 000000000..60b8950f2 --- /dev/null +++ b/src/network_systems/projects/example/test/test_cached_fib.cpp @@ -0,0 +1,29 @@ +#include + +#include "cached_fib.h" + +static constexpr int DEFAULT_SIZE = 5; +static CachedFib g_test_fib = CachedFib(DEFAULT_SIZE); + +class TestFib : public ::testing::Test +{ +protected: + TestFib() + { + // Every time a test is started, testFib is reinitialized with a constructor parameter of 5 + g_test_fib = CachedFib(DEFAULT_SIZE); + } + + ~TestFib() override + { + // Clean up after a test + } +}; + +TEST_F(TestFib, TestBasic) { ASSERT_EQ(g_test_fib.getFib(5), 3) << "5th fibonacci number must be 3!"; } + +TEST_F(TestFib, TestBasic2) +{ + ASSERT_EQ(g_test_fib.getFib(6), 5); + ASSERT_EQ(g_test_fib.getFib(7), 8); +} diff --git a/src/network_systems/projects/local_transceiver/CMakeLists.txt b/src/network_systems/projects/local_transceiver/CMakeLists.txt new file mode 100644 index 000000000..be94351de --- /dev/null +++ b/src/network_systems/projects/local_transceiver/CMakeLists.txt @@ -0,0 +1,32 @@ +set(module local_transceiver) + +set(link_libs + ${PROTOBUF_LINK_LIBS} +) + +set(inc_dirs + ${PROTOBUF_INCLUDE_PATH} +) + +set(compile_defs + LOCAL_TRANSCEIVER_TEST_PORT="$ENV{LOCAL_TRANSCEIVER_TEST_PORT}" + RUN_VIRTUAL_IRIDIUM_SCRIPT_PATH="$ENV{ROS_WORKSPACE}/src/network_systems/scripts/run_virtual_iridium.sh" +) + +set(srcs + ${CMAKE_CURRENT_LIST_DIR}/src/local_transceiver.cpp +) + +# Create module ROS executable +set(bin_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/src/local_transceiver_ros_intf.cpp +) +make_exe(${module} "${bin_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +# Create unit test +set(test_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/test/test_local_transceiver.cpp +) +make_unit_test(${module} "${test_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") diff --git a/src/network_systems/projects/local_transceiver/diagrams/common.puml b/src/network_systems/projects/local_transceiver/diagrams/common.puml new file mode 100644 index 000000000..63c21478a --- /dev/null +++ b/src/network_systems/projects/local_transceiver/diagrams/common.puml @@ -0,0 +1,19 @@ +' To be included by other files. Do not use on its own. +@startuml common + +box Modules +participant "Local Transceiver ROS Intf" as intf +participant "Local Transceiver" as local +end box + +box Resources +Collections "Sensor Buffer" as buf +Queue "Serial Port" as port +end box + +note across + As ROS is used for synchronizing access to resources, the module control + flow is blocking, meaning only one subsequence can happen at at a time. +end note + +@enduml diff --git a/src/network_systems/projects/local_transceiver/diagrams/receive_global_path_sequence.puml b/src/network_systems/projects/local_transceiver/diagrams/receive_global_path_sequence.puml new file mode 100644 index 000000000..e5a672fbd --- /dev/null +++ b/src/network_systems/projects/local_transceiver/diagrams/receive_global_path_sequence.puml @@ -0,0 +1,24 @@ +@startuml receive_global_path_sequence +title Local Transceiver Receive Global Path Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") +!include common.puml + +autonumber + +== Update Global Waypoints Subsequence == + +note over buf : Unused + +intf -> intf ++ : Update Timer Triggers +intf -> local ++ : Get Global Waypoints +local -> local : Create Global Waypoints Object +loop While Iridium Mailbox is Not Empty &&\nThere are Global Waypoints Remaining + local --> port : Issue AT Read Command + local <-- port : Read response + local -> local : Update Global Waypoints Object +end +opt If New Global Waypoints + intf <- local -- : Return New Global Waypoints + <- intf -- : Publish Global Waypoints +end diff --git a/src/network_systems/projects/local_transceiver/diagrams/transmit_sensors_sequence.puml b/src/network_systems/projects/local_transceiver/diagrams/transmit_sensors_sequence.puml new file mode 100644 index 000000000..c0fc22755 --- /dev/null +++ b/src/network_systems/projects/local_transceiver/diagrams/transmit_sensors_sequence.puml @@ -0,0 +1,40 @@ +@startuml transmit_sensors_sequence +title Local Transceiver Transmit Sensors Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") +!include common.puml + +autonumber + +== Update Sensors Subsequence == + +note across : Very Frequent - On Demand Execution + +-> intf ++ : New Sensor Data +intf -> local ++ : Update Sensor +local -> local : Convert Data from ROS to Google Protobuf +local --> buf : Store Sensor in Buffer +deactivate intf +deactivate local + +... + +== Transmit Sensors Subsequence == + +note across : Very Infrequent - Execute Every Few Hours + +intf -> intf ++ : Transmit Timer Triggers +intf -> local ++ : Start Transmission +local <-- buf : Read Current Sensors +local -> local : Serialize Sensor Data +local -> local : Create AT Write Binary Command With Data +note right of local : If payload is too large, split the data across multiple commands +loop Until Successful or Unsuccessful Attempts + local --> port : Write Command + local <-- port : Read Response +end +opt If Failed to Transmit Sensors Data + intf <- local -- : Return failure + intf -> intf : Set Shorter Transmit Timer Interval +end +deactivate intf diff --git a/src/network_systems/projects/local_transceiver/inc/at_cmds.h b/src/network_systems/projects/local_transceiver/inc/at_cmds.h new file mode 100644 index 000000000..1e1c18999 --- /dev/null +++ b/src/network_systems/projects/local_transceiver/inc/at_cmds.h @@ -0,0 +1,58 @@ +#pragma once + +// Full command set: https://cdn-shop.adafruit.com/product-files/4521/4521-AT%20command.pdf +// Section numbers in this header file refer to this document + +#include +#include + +namespace AT +{ +const std::string DELIMITER = "\r"; +const std::string STATUS_OK = "OK"; + +const std::string CHECK_CONN = "AT" + DELIMITER; +const std::string SBD_SESSION = "AT+SBDIX" + DELIMITER; // 5.144 + +/** + * Struct representing the response to the CHECK_STATUS command + * 5.144 + */ +struct SBDStatusResponse // TODO(Jng468): Implement this class +{ + static constexpr uint8_t MO_SUCCESS_START = 0; + static constexpr uint8_t MO_SUCCESS_END = 5; + + uint8_t MO_status_; + uint16_t MOMSN_; + uint8_t MT_status_; + uint16_t MTMSN_; + uint8_t MT_len_; + uint8_t MT_queued_; + + /** + * @brief Construct a new Status Response object + * + * @param rsp_string string of format "+SBDIX:,,,,,"" + */ + explicit SBDStatusResponse(const std::string & rsp_string) + { + (void)rsp_string; + MO_status_ = 0; + MOMSN_ = 0; + MT_status_ = 0; + MTMSN_ = 0; + MT_len_ = 0; + MT_queued_ = 0; + }; + + /** + * @brief Check if last Mobile Originated (i.e. transmitted sensors) transaction was successful + * + * @return true on success + * @return false on failure + */ + bool MOSuccess() const { return MO_status_ < MO_SUCCESS_END; } +}; + +} // namespace AT diff --git a/src/network_systems/projects/local_transceiver/inc/local_transceiver.h b/src/network_systems/projects/local_transceiver/inc/local_transceiver.h new file mode 100644 index 000000000..ac269e829 --- /dev/null +++ b/src/network_systems/projects/local_transceiver/inc/local_transceiver.h @@ -0,0 +1,168 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sensors.pb.h" + +namespace msg = custom_interfaces::msg; + +constexpr unsigned int SATELLITE_BAUD_RATE = 19200; + +/** + * Implementation of Local Transceiver that operates through a serial interface + */ +class LocalTransceiver +{ +public: + /** + * @brief Update the sensor with new GPS data + * + * @param gps custom_interfaces gps object + */ + void updateSensor(msg::GPS gps); + + /** + * @brief Update the sensor with new AIS Ships data + * + * @param ships custom_interfaces AISShips object + */ + void updateSensor(msg::AISShips ships); + + /** + * @brief Update the sensor with new Wind sensor data + * + * @param wind custom_interfaces WindSensors object + */ + void updateSensor(msg::WindSensors wind); + + /** + * @brief Update the sensor with new battery data + * + * @param battery custom_interfaces Batteries object + */ + void updateSensor(msg::Batteries battery); + + /** + * @brief Update the sensor with new generic sensor data + * + * @param generic custom_interfaces GenericSensors object + */ + void updateSensor(msg::GenericSensors msg); + + /** + * @brief Update the sensor with new local path data + * + * @param localData custom_interfaces LPathData object + */ + void updateSensor(msg::LPathData localData); + + /** + * @brief Get a copy of the sensors object + * + * @return Copy of sensors_ + */ + Polaris::Sensors sensors(); + + /** + * @brief Construct a new Local Transceiver object and connect it to a serial port + * + * @param port_name serial port (ex. /dev/ttyS0) + * @param baud_rate baud rate of the serial port + */ + LocalTransceiver(const std::string & port_name, uint32_t baud_rate); + + /** + * @brief Destroy the Local Transceiver object + * + * @note must call stop() to properly cleanup the object + * + * @param sensor new sensor data + */ + ~LocalTransceiver(); + + /** + * @brief Cleanup the Local Transceiver object by closing the serial port + * + * @note must be called before the object is destroyed + * + */ + void stop(); + + /** + * @brief Send current data to the serial port and to the remote server + * + * @return true on success + * @return false on failure + */ + bool send(); + + /** + * @brief Send a debug command and return the output + * + * @param cmd string to send to the serial port + * @return output of the sent cmd + */ + std::string debugSend(const std::string & cmd); + + /** + * @brief Retrieve the latest message from the remote server via the serial port + * + * @return The message as a binary string + */ + std::string receive(); + +private: + // boost io service - required for boost::asio operations + boost::asio::io_service io_; + // serial port data where is sent and received + boost::asio::serial_port serial_; + // underlying sensors object + Polaris::Sensors sensors_; + + /** + * @brief Send a command to the serial port + * + * @param cmd command to send + */ + void send(const std::string & cmd); + + /** + * @brief Parse the message received from the remote server + * + * @param msg message received from the remote server + * @return the data byte string payload from the message + */ + static std::string parseInMsg(const std::string & msg); + + /** + * @brief Read a line from serial + * + * @return line + */ + std::string readLine(); + + /** + * @brief Check that the last command sent to serial was valid + * + * @return true if valid + * @return false if invalid + */ + bool checkOK(); + + /** + * @brief Compute a checksum + * + * @param data data string + * @return checksum as a string + */ + static std::string checksum(const std::string & data); +}; diff --git a/src/network_systems/projects/local_transceiver/src/local_transceiver.cpp b/src/network_systems/projects/local_transceiver/src/local_transceiver.cpp new file mode 100644 index 000000000..c24cc5ec9 --- /dev/null +++ b/src/network_systems/projects/local_transceiver/src/local_transceiver.cpp @@ -0,0 +1,209 @@ +#include "local_transceiver.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "at_cmds.h" +#include "cmn_hdrs/ros_info.h" +#include "cmn_hdrs/shared_constants.h" +#include "sensors.pb.h" +#include "waypoint.pb.h" + +using Polaris::Sensors; +namespace bio = boost::asio; + +void LocalTransceiver::updateSensor(msg::GPS gps) +{ + sensors_.mutable_gps()->set_heading(gps.heading.heading); + sensors_.mutable_gps()->set_latitude(gps.lat_lon.latitude); + sensors_.mutable_gps()->set_longitude(gps.lat_lon.longitude); + sensors_.mutable_gps()->set_speed(gps.speed.speed); +} + +void LocalTransceiver::updateSensor(msg::AISShips ships) +{ + sensors_.clear_ais_ships(); + for (const msg::HelperAISShip & ship : ships.ships) { + Sensors::Ais * new_ship = sensors_.add_ais_ships(); + new_ship->set_id(ship.id); + new_ship->set_cog(ship.cog.heading); + new_ship->set_latitude(ship.lat_lon.latitude); + new_ship->set_longitude(ship.lat_lon.longitude); + new_ship->set_sog(ship.sog.speed); + new_ship->set_rot(ship.rot.rot); + new_ship->set_width(ship.width.dimension); + new_ship->set_length(ship.length.dimension); + } +} + +void LocalTransceiver::updateSensor(msg::WindSensors wind) +{ + sensors_.clear_wind_sensors(); + for (const msg::WindSensor & wind_data : wind.wind_sensors) { + Sensors::Wind * new_wind = sensors_.add_wind_sensors(); + new_wind->set_direction(wind_data.direction); + new_wind->set_speed(wind_data.speed.speed); + } +} + +void LocalTransceiver::updateSensor(msg::Batteries battery) +{ + sensors_.clear_batteries(); + for (const msg::HelperBattery & battery_info : battery.batteries) { + Sensors::Battery * new_battery = sensors_.add_batteries(); + new_battery->set_current(battery_info.current); + new_battery->set_voltage(battery_info.voltage); + } +} + +void LocalTransceiver::updateSensor(msg::GenericSensors msg) +{ + sensors_.clear_data_sensors(); + for (const msg::HelperGenericSensor & sensors_data : msg.generic_sensors) { + Sensors::Generic * new_sensor = sensors_.add_data_sensors(); + new_sensor->set_data(sensors_data.data); + new_sensor->set_id(sensors_data.id); + } +} + +void LocalTransceiver::updateSensor(msg::LPathData localData) +{ + sensors_.clear_local_path_data(); + for (const msg::HelperLatLon & local_data : localData.local_path.waypoints) { + Sensors::Path * new_local = sensors_.mutable_local_path_data(); + Polaris::Waypoint * waypoint = new_local->add_waypoints(); + waypoint->set_latitude(local_data.latitude); + waypoint->set_longitude(local_data.longitude); + } +} + +Sensors LocalTransceiver::sensors() { return sensors_; } + +LocalTransceiver::LocalTransceiver(const std::string & port_name, const uint32_t baud_rate) : serial_(io_, port_name) +{ + serial_.set_option(bio::serial_port_base::baud_rate(baud_rate)); +}; + +LocalTransceiver::~LocalTransceiver() +{ + // Intentionally left blank +} + +void LocalTransceiver::stop() +{ + serial_.cancel(); + serial_.close(); // Can throw an exception so cannot be put in the destructor +} + +bool LocalTransceiver::send() +{ + std::string data; + // Make sure to get a copy of the sensors because repeated calls may give us different results + Polaris::Sensors sensors = sensors_; + + if (!sensors.SerializeToString(&data)) { + std::cerr << "Failed to serialize sensors string" << std::endl; + std::cerr << sensors.DebugString() << std::endl; + return false; + } + if (data.size() >= MAX_LOCAL_TO_REMOTE_PAYLOAD_SIZE_BYTES) { + // if this proves to be a problem, we need a solution to split the data into multiple messages + std::string err_string = + "Data too large!\n" + "Attempted: " + + std::to_string(data.size()) + " bytes\n" + sensors.DebugString() + + "\n" + "No implementation to handle this!"; + throw std::length_error(err_string); + } + + static constexpr int MAX_NUM_RETRIES = 20; + for (int i = 0; i < MAX_NUM_RETRIES; i++) { + std::string sbdwbCommand = "AT+SBDWB=" + std::to_string(data.size()) + "\r"; + send(sbdwbCommand + data + "\r"); + + std::string checksumCommand = std::to_string(data.size()) + checksum(data) + "\r"; + send(data + "+" + checksumCommand + "\r"); + + // Check SBD Session status to see if data was sent successfully + send(AT::SBD_SESSION); + std::string rsp_str = readLine(); + readLine(); // empty line after response + if (checkOK()) { + try { + AT::SBDStatusResponse rsp(rsp_str); + if (rsp.MOSuccess()) { + return true; + } + } catch (std::invalid_argument & e) { + /* Catch response parsing exceptions */ + } + } + } + return false; +} + +std::string LocalTransceiver::debugSend(const std::string & cmd) +{ + send(cmd); + + std::string response = readLine(); // Read and capture the response + readLine(); // Check if there is an empty line after respones + return response; +} + +std::string LocalTransceiver::receive() +{ + std::string receivedData = readLine(); + return receivedData; +} + +void LocalTransceiver::send(const std::string & cmd) { bio::write(serial_, bio::buffer(cmd, cmd.size())); } + +std::string LocalTransceiver::parseInMsg(const std::string & msg) +{ + //TODO(jng468): implement function + (void)msg; + return "placeholder"; +} + +std::string LocalTransceiver::readLine() +{ + bio::streambuf buf; + + // Caution: will hang if another proccess is reading from serial port + bio::read_until(serial_, buf, AT::DELIMITER); + return std::string( + bio::buffers_begin(buf.data()), bio::buffers_begin(buf.data()) + static_cast(buf.data().size())); +} + +bool LocalTransceiver::checkOK() +{ + std::string status = readLine(); + return status == AT::STATUS_OK; +} + +std::string LocalTransceiver::checksum(const std::string & data) +{ + uint16_t counter = 0; + for (char c : data) { + counter += static_cast(c); + } + + std::stringstream ss; + ss << std::hex << std::setw(4) << std::setfill('0') << counter; + return ss.str(); +} diff --git a/src/network_systems/projects/local_transceiver/src/local_transceiver_ros_intf.cpp b/src/network_systems/projects/local_transceiver/src/local_transceiver_ros_intf.cpp new file mode 100644 index 000000000..e5cb5bc7d --- /dev/null +++ b/src/network_systems/projects/local_transceiver/src/local_transceiver_ros_intf.cpp @@ -0,0 +1,72 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "cmn_hdrs/ros_info.h" +#include "cmn_hdrs/shared_constants.h" +#include "local_transceiver.h" + +/** + * Local Transceiver Interface Node + * + */ +class LocalTransceiverIntf : public rclcpp::Node +{ +public: + /** + * @brief Construct a new Local Transceiver Intf Node + * + * @param lcl_trns Local Transceiver instance + */ + explicit LocalTransceiverIntf(std::shared_ptr lcl_trns) + : Node("local_transceiver_node"), lcl_trns_(lcl_trns) + { + static constexpr int ROS_Q_SIZE = 5; + static constexpr auto TIMER_INTERVAL = std::chrono::milliseconds(500); + pub_ = this->create_publisher(PLACEHOLDER_TOPIC_0_TOPIC, ROS_Q_SIZE); + timer_ = this->create_wall_timer(TIMER_INTERVAL, std::bind(&LocalTransceiverIntf::pub_cb, this)); + sub_ = this->create_subscription( + PLACEHOLDER_TOPIC_1_TOPIC, ROS_Q_SIZE, std::bind(&LocalTransceiverIntf::sub_cb, this, std::placeholders::_1)); + } + +private: + // Local Transceiver instance + std::shared_ptr lcl_trns_; + // Publishing timer + rclcpp::TimerBase::SharedPtr timer_; + // String is a placeholder pub and sub msg type - we will definitely define custom message types + rclcpp::Publisher::SharedPtr pub_; + // Placeholder subscriber object + rclcpp::Subscription::SharedPtr sub_; + + /** + * @brief Callback function to publish to onboard ROS network + * + */ + void pub_cb(/* placeholder */) + { + //TODO(jng468) + } + + /** + * @brief Callback function to subscribe to the onboard ROS network + * + */ + void sub_cb(std_msgs::msg::String /* placeholder */) + { + //TODO(jng468) + } +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + std::shared_ptr lcl_trns = std::make_shared("PLACEHOLDER", SATELLITE_BAUD_RATE); + rclcpp::spin(std::make_shared(lcl_trns)); + rclcpp::shutdown(); + return 0; +} diff --git a/src/network_systems/projects/local_transceiver/test/test_local_transceiver.cpp b/src/network_systems/projects/local_transceiver/test/test_local_transceiver.cpp new file mode 100644 index 000000000..b845f7bfd --- /dev/null +++ b/src/network_systems/projects/local_transceiver/test/test_local_transceiver.cpp @@ -0,0 +1,56 @@ +/* IMPORTANT: Make sure only one instance of network_systems/scripts/run_virtual_iridium.sh is running */ + +#include + +#include + +#include "cmn_hdrs/shared_constants.h" +#include "local_transceiver.h" +#include "sensors.pb.h" + +class TestLocalTransceiver : public ::testing::Test +{ +protected: + TestLocalTransceiver() + { + try { + lcl_trns_ = new LocalTransceiver(LOCAL_TRANSCEIVER_TEST_PORT, SATELLITE_BAUD_RATE); + } catch (boost::system::system_error & /**/) { + std::cerr << "Failed to create Local Transceiver for tests, is only one instance of: \"" + << RUN_VIRTUAL_IRIDIUM_SCRIPT_PATH << "\" running?" << std::endl; + } + } + ~TestLocalTransceiver() override + { + lcl_trns_->stop(); + delete lcl_trns_; + } + + LocalTransceiver * lcl_trns_; +}; + +/** + * @brief Verify debugSendTest sends something to the terminal + */ +TEST_F(TestLocalTransceiver, debugSendTest) +{ + //std::string testDebug = "showsUp"; + //lcl_trns_->debugSend(testDebug); +} + +/** + * @brief Send a binary string to virtual_iridium and verify it is received + * Uses gps custom interface + */ +TEST_F(TestLocalTransceiver, sendGpsTest) +{ + constexpr float holder = 14.3; + + custom_interfaces::msg::GPS gps; + gps.heading.set__heading(holder); + gps.lat_lon.set__latitude(holder); + gps.lat_lon.set__longitude(holder); + gps.speed.set__speed(holder); + lcl_trns_->updateSensor(gps); + lcl_trns_->send(); +} diff --git a/src/network_systems/projects/mock_ais/CMakeLists.txt b/src/network_systems/projects/mock_ais/CMakeLists.txt new file mode 100644 index 000000000..71978a1ab --- /dev/null +++ b/src/network_systems/projects/mock_ais/CMakeLists.txt @@ -0,0 +1,29 @@ +set(module mock_ais) + +# define external dependencies with link_libs and inc_dirs variables +set(link_libs +) + +set(inc_dirs +) + +set(compile_defs +) + +set(srcs + ${CMAKE_CURRENT_LIST_DIR}/src/mock_ais.cpp +) + +# Create module ROS executable +set(bin_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/src/mock_ais_ros_intf.cpp +) +make_exe(${module} "${bin_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +# Create unit test +set(test_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/test/test_mock_ais.cpp +) +make_unit_test(${module} "${test_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") diff --git a/src/network_systems/projects/mock_ais/inc/mock_ais.h b/src/network_systems/projects/mock_ais/inc/mock_ais.h new file mode 100644 index 000000000..c67237bad --- /dev/null +++ b/src/network_systems/projects/mock_ais/inc/mock_ais.h @@ -0,0 +1,199 @@ +#pragma once + +#include +#include +#include + +namespace qvm = boost::qvm; + +/** + * @brief Convenience struct for a 2D floating point vector + * All functionality is inherited from qvm::vec + */ +struct Vec2DFloat : public qvm::vec +{ + // Explicitly use the member variable 'a' in the base qvm::vec class + using qvm::vec::a; + + /** + * @brief Instantiate an empty Vec2DFloat + * + */ + Vec2DFloat() {} + + /** + * @brief Instantiate a Vec2DFloat with initial component values + * + * @param i + * @param j + */ + Vec2DFloat(float i, float j) : qvm::vec{i, j} {} + + /** + * @brief Convenience array accessor for a + * + * @param idx + * @return float& + */ + float & operator[](std::size_t idx) { return a[idx]; } + + /** + * @brief Convenience array accessor for a + * + * @param idx + * @return const float& + */ + const float & operator[](std::size_t idx) const { return a[idx]; } +}; + +// NOLINTBEGIN +// This boost::qvm:: namespace segment is just boilerplate to register the Vec2DFloat type with qvm's vector operations. +// See https://www.boost.org/doc/libs/1_74_0/libs/qvm/doc/html/index.html#vec_traits for more info. +// Even though its boilerplate, it triggers linter errors so disable linting for this section. +namespace boost +{ +namespace qvm +{ +template <> +struct vec_traits +{ + static int const dim = 2; + + using scalar_type = float; + + template + static inline scalar_type & write_element(Vec2DFloat & v) + { + return v.a[I]; + } + + template + static inline scalar_type read_element(Vec2DFloat const & v) + { + return v.a[I]; + } +}; +} // namespace qvm +} // namespace boost +// NOLINTEND +namespace defaults +{ +constexpr float MAX_HEADING_CHANGE = 2.0; // Max degree change per tick +constexpr float MAX_SPEED_CHANGE = 1.0; // Min degree change per tick +constexpr float MIN_AIS_SHIP_DIST = 0.001; // Min 111m (at equator) distance of ais ships from Polaris +constexpr float MAX_AIS_SHIP_DIST = 0.1; // Max 11.1km (at equator) distance of ais ships from Polaris +constexpr int MIN_AIS_SHIP_WIDTH_M = 2; // A boat this small likely won't have AIS +constexpr int MAX_AIS_SHIP_WIDTH_M = 49; // Typical container ship width +// Minimum and maximum ratios pulled from: http://marine.marsh-design.com/content/length-beam-ratio +constexpr int MIN_AIS_SHIP_L_W_RATIO = 2; // Ship length should be at least 2x width +constexpr int MAX_AIS_SHIP_L_W_RATIO = 16; // Ship length should be at most 16x width +constexpr int UPDATE_RATE_MS = 500; // Update frequency +constexpr int SEED = 123456; // Randomization seed +constexpr int NUM_SIM_SHIPS = 20; // Number of ais ships to simulate +const Vec2DFloat POLARIS_START_POS{49.28397458822112, -123.6525841364974}; // some point in the Strait of Georgia; +} // namespace defaults + +/** + * @brief Struct that mirrors the definition of custom_interfaces::msg::HelperAISShip + * + */ +struct AisShip +{ + Vec2DFloat lat_lon_; + float speed_; + float heading_; + uint32_t id_; + uint32_t width_; + uint32_t length_; + int8_t rot_; +}; + +/** + * @brief Extra per ship simulation parameters + */ +struct SimShipConfig +{ + float max_heading_change_; // Max degree change per tick + float max_speed_change_; // Min degree change per tick + float max_ship_dist_; // Maximum distance from Polaris + float min_ship_dist_; // Minimum distance from Polaris + uint32_t min_ship_width_m_; // Minimum ship width in meters + uint32_t max_ship_width_m_; // Maximum ship width in meters + uint32_t min_ship_l_w_ratio_; // Minimum ship length:width ratio + uint32_t max_ship_l_w_ratio_; // Maximum ship length:width ratio +}; + +class MockAisShip : public AisShip +{ +public: + /** + * @brief Construct a new Mock Ais Ship object + * + * @param seed Random seed + * @param id ID of the new sim ship + * @param polaris_lat_lon Position of Poalris + * @param config Sim ship constraints + */ + MockAisShip(uint32_t seed, uint32_t id, Vec2DFloat polaris_lat_lon, SimShipConfig config); + + /** + * @brief Update the AIS Ship instance + * + * @param polaris_lat_lon Current position of Polaris + */ + void tick(const Vec2DFloat & polaris_lat_lon); + +private: + std::mt19937 mt_rng_; // Random number generator + SimShipConfig config_; // Sim ship constraints +}; + +/** + * Simulate Ais Ships + */ +class MockAis +{ +public: + /** + * @brief Construct a new Mock AIS simulation + * + * @param seed Random seeds + * @param num_ships Number of sim ships to spawn + * @param polaris_lat_lon Position of Polaris + */ + MockAis(uint32_t seed, uint32_t num_ships, Vec2DFloat polaris_lat_lon); + + /** + * @brief Construct a new Mock AIS simulation + * + * @param seed Random seeds + * @param num_ships Number of sim ships to spawn + * @param polaris_lat_lon Position of Polaris + * @param config Extra sim ship constraints + */ + MockAis(uint32_t seed, uint32_t num_ships, Vec2DFloat polaris_lat_lon, SimShipConfig config); + + /** + * @brief Get the current AIS ships + * + * @return A vector of AisShip objects + */ + std::vector ships() const; + + /** + * @brief Update the current position of Polaris + * + * @param lat_lon Polaris' position + */ + void updatePolarisPos(const Vec2DFloat & lat_lon); + + /** + * @brief Update every simulated AIS ship + * + */ + void tick(); + +private: + Vec2DFloat polaris_lat_lon_; // Polaris' current position + std::vector ships_; // Vector of all simulated Ais ships +}; diff --git a/src/network_systems/projects/mock_ais/src/mock_ais.cpp b/src/network_systems/projects/mock_ais/src/mock_ais.cpp new file mode 100644 index 000000000..25b979b86 --- /dev/null +++ b/src/network_systems/projects/mock_ais/src/mock_ais.cpp @@ -0,0 +1,153 @@ +#include "mock_ais.h" + +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" + +namespace +{ +/** + * @brief Convert degress to radians + * + * @param degrees + * @return radians + */ +float degToRad(const float & degrees) +{ + return static_cast(degrees * M_PI / 180.0); // NOLINT(readability-magic-numbers) +} + +/** + * @brief Bound a heading to our current limits + * + * @param heading + * @return bounded heading + */ +float boundHeading(const float & heading) +{ + if (heading < HEADING_LBND) { + return heading + HEADING_UBND; + } + if (heading >= HEADING_UBND) { + return heading - HEADING_UBND; + } + return heading; +} + +/** + * @brief Convert a heading to a 2D direction unit vector + * + * @param heading + * @return unit direction vector + */ +Vec2DFloat headingToVec2D(const float & heading) +{ + float angle = degToRad(heading); + // Since 0 is north (y-axis), use sin for x and cos for y + return {std::sin(angle), std::cos(angle)}; +} +} // namespace + +MockAisShip::MockAisShip(uint32_t seed, uint32_t id, Vec2DFloat polaris_lat_lon, SimShipConfig config) +: mt_rng_(seed), config_(config) +{ + static const std::array pos_or_neg = {-1.0, 1.0}; + std::uniform_real_distribution lat_dist(config_.min_ship_dist_, config_.max_ship_dist_); + std::uniform_real_distribution lon_dist(config_.min_ship_dist_, config_.max_ship_dist_); + std::uniform_real_distribution speed_dist(SPEED_LBND, SPEED_UBND); + std::uniform_real_distribution heading_dist(HEADING_LBND, HEADING_UBND); + std::uniform_int_distribution pos_or_neg_dist(0, 1); + std::uniform_int_distribution beam_dist(config_.min_ship_width_m_, config_.max_ship_width_m_); + std::uniform_int_distribution l_w_ratio_dist(config_.min_ship_l_w_ratio_, config_.max_ship_l_w_ratio_); + std::uniform_int_distribution rot_dist(ROT_LBND, ROT_UBND); + + id_ = id; + lat_lon_ = { + polaris_lat_lon[0] + pos_or_neg[pos_or_neg_dist(mt_rng_)] * lat_dist(mt_rng_), + polaris_lat_lon[1] + pos_or_neg[pos_or_neg_dist(mt_rng_)] * lon_dist(mt_rng_)}; + speed_ = speed_dist(mt_rng_); + heading_ = heading_dist(mt_rng_); + width_ = beam_dist(mt_rng_); + length_ = width_ * l_w_ratio_dist(mt_rng_); + rot_ = rot_dist(mt_rng_); +} + +void MockAisShip::tick(const Vec2DFloat & polaris_lat_lon) +{ + std::uniform_real_distribution heading_dist( + heading_ - config_.max_heading_change_, heading_ + config_.max_heading_change_); + std::uniform_real_distribution speed_dist( + speed_ - config_.max_speed_change_, speed_ + config_.max_speed_change_); + std::uniform_int_distribution rot_dist(ROT_LBND, ROT_UBND); + + float speed = speed_dist(mt_rng_); + if (speed > SPEED_UBND) { + speed = SPEED_UBND; + } else if (speed < SPEED_LBND) { + speed = SPEED_LBND; + } + + heading_ = boundHeading(heading_dist(mt_rng_)); + speed_ = speed; + Vec2DFloat dir_vec = headingToVec2D(heading_); + Vec2DFloat displacement = dir_vec * speed_; + Vec2DFloat new_pos = lat_lon_ + displacement; + Vec2DFloat polaris_to_new_pos_vec = new_pos - polaris_lat_lon; + float polaris_to_new_pos_distance = qvm::mag(polaris_to_new_pos_vec); + + if (polaris_to_new_pos_distance < config_.min_ship_dist_) { + qvm::normalize(polaris_to_new_pos_vec); + lat_lon_ = polaris_lat_lon + config_.min_ship_dist_ * polaris_to_new_pos_vec; + } else if (polaris_to_new_pos_distance > config_.max_ship_dist_) { + qvm::normalize(polaris_to_new_pos_vec); + lat_lon_ = polaris_lat_lon + config_.max_ship_dist_ * polaris_to_new_pos_vec; + } else { + lat_lon_ = new_pos; + } + + // ROT does not necessarily depend on actual change in heading, so we can use a random number + rot_ = rot_dist(mt_rng_); +} + +MockAis::MockAis(uint32_t seed, uint32_t num_ships, Vec2DFloat polaris_lat_lon) +: MockAis( + seed, num_ships, polaris_lat_lon, + {.max_heading_change_ = defaults::MAX_HEADING_CHANGE, + .max_speed_change_ = defaults::MAX_SPEED_CHANGE, + .max_ship_dist_ = defaults::MAX_AIS_SHIP_DIST, + .min_ship_dist_ = defaults::MIN_AIS_SHIP_DIST, + .min_ship_width_m_ = defaults::MIN_AIS_SHIP_WIDTH_M, + .max_ship_width_m_ = defaults::MAX_AIS_SHIP_WIDTH_M, + .min_ship_l_w_ratio_ = defaults::MIN_AIS_SHIP_L_W_RATIO, + .max_ship_l_w_ratio_ = defaults::MAX_AIS_SHIP_L_W_RATIO}) +{ +} + +MockAis::MockAis(uint32_t seed, uint32_t num_ships, Vec2DFloat polaris_lat_lon, SimShipConfig config) +: polaris_lat_lon_(polaris_lat_lon) +{ + for (uint32_t i = 0; i < num_ships; i++) { + ships_.push_back(MockAisShip(seed + i, i, polaris_lat_lon, config)); + } +} + +std::vector MockAis::ships() const +{ + std::vector ships; + for (const MockAisShip & ship : ships_) { + ships.push_back(ship); + } + return ships; +} + +void MockAis::updatePolarisPos(const Vec2DFloat & lat_lon) { polaris_lat_lon_ = lat_lon; } + +void MockAis::tick() +{ + for (MockAisShip & ship : ships_) { + ship.tick(polaris_lat_lon_); + } +} diff --git a/src/network_systems/projects/mock_ais/src/mock_ais_ros_intf.cpp b/src/network_systems/projects/mock_ais/src/mock_ais_ros_intf.cpp new file mode 100644 index 000000000..e19ed9342 --- /dev/null +++ b/src/network_systems/projects/mock_ais/src/mock_ais_ros_intf.cpp @@ -0,0 +1,125 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cmn_hdrs/ros_info.h" +#include "cmn_hdrs/shared_constants.h" +#include "mock_ais.h" + +/** + * Connect the Mock AIS to the onbaord ROS network + */ +class MockAisRosIntf : public rclcpp::Node +{ +public: + MockAisRosIntf() : Node("mock_ais_node") + { + static constexpr int ROS_Q_SIZE = 5; + this->declare_parameter("enabled", false); + + if (this->get_parameter("enabled").as_bool()) { + this->declare_parameter("mode", rclcpp::PARAMETER_STRING); + this->declare_parameter("publish_rate_ms", defaults::UPDATE_RATE_MS); + this->declare_parameter("seed", defaults::SEED); + this->declare_parameter("num_sim_ships", defaults::NUM_SIM_SHIPS); + this->declare_parameter( + "polaris_start_pos", + std::vector({defaults::POLARIS_START_POS[0], defaults::POLARIS_START_POS[1]})); + + rclcpp::Parameter mode_param = this->get_parameter("mode"); + rclcpp::Parameter publish_rate_ms_param = this->get_parameter("publish_rate_ms"); + rclcpp::Parameter seed_param = this->get_parameter("seed"); + rclcpp::Parameter num_sim_ships_param = this->get_parameter("num_sim_ships"); + rclcpp::Parameter polaris_start_pos_param = this->get_parameter("polaris_start_pos"); + + std::string mode = mode_param.as_string(); + int64_t publish_rate_ms = publish_rate_ms_param.as_int(); + int64_t seed = seed_param.as_int(); + int64_t num_sim_ships = num_sim_ships_param.as_int(); + Vec2DFloat polaris_start_pos = {// annoyingly ugly type conversion :/ + static_cast(polaris_start_pos_param.as_double_array()[0]), + static_cast(polaris_start_pos_param.as_double_array()[1])}; + + RCLCPP_INFO( + this->get_logger(), + "Running Mock AIS in mode: %s, with publish_rate_ms: %s, seed: %s, num_ships %s, polaris_start_pos: %s", + mode.c_str(), publish_rate_ms_param.value_to_string().c_str(), seed_param.value_to_string().c_str(), + num_sim_ships_param.value_to_string().c_str(), polaris_start_pos_param.value_to_string().c_str()); + + // TODO(): Add ROS parameters so that we can use the MockAis constructor that takes SimShipConfig + // Optionally use nested parameters: https://answers.ros.org/question/325939/declare-nested-parameter/ + mock_ais_ = std::make_unique(seed, num_sim_ships, polaris_start_pos); + std::string polaris_gps_topic = mode == SYSTEM_MODE::DEV ? MOCK_GPS_TOPIC : GPS_TOPIC; + + // The subscriber callback is very simple so it's just the following lambda function + sub_ = this->create_subscription( + polaris_gps_topic, ROS_Q_SIZE, [&mock_ais_ = mock_ais_](custom_interfaces::msg::GPS mock_gps) { + mock_ais_->updatePolarisPos({mock_gps.lat_lon.latitude, mock_gps.lat_lon.longitude}); + }); + + pub_ = this->create_publisher(MOCK_AIS_SHIPS_TOPIC, ROS_Q_SIZE); + timer_ = this->create_wall_timer( + std::chrono::milliseconds(publish_rate_ms), std::bind(&MockAisRosIntf::pubShipsCB, this)); + } else { + RCLCPP_INFO(this->get_logger(), "Mock AIS is DISABLED"); + } + } + +private: + std::unique_ptr mock_ais_; // Mock AIS instance + rclcpp::TimerBase::SharedPtr timer_; // publish timer + rclcpp::Publisher::SharedPtr pub_; // Publish new AISShips info + rclcpp::Subscription::SharedPtr sub_; // Subscribe to Polaris' GPS coordinates + + /** + * @brief Publish the latest mock ais ships data + * + */ + void pubShipsCB() + { + mock_ais_->tick(); + std::vector ais_ships = mock_ais_->ships(); + custom_interfaces::msg::AISShips msg{}; + for (const AisShip & ais_ship : ais_ships) { + custom_interfaces::msg::HelperAISShip helper_ship; + helper_ship.set__id(static_cast(ais_ship.id_)); + custom_interfaces::msg::HelperHeading helper_head; + helper_head.set__heading(ais_ship.heading_); + helper_ship.set__cog(helper_head); + custom_interfaces::msg::HelperSpeed helper_speed; + helper_speed.set__speed(ais_ship.speed_); + helper_ship.set__sog(helper_speed); + custom_interfaces::msg::HelperLatLon lat_lon; + lat_lon.set__latitude(ais_ship.lat_lon_[0]); + lat_lon.set__longitude(ais_ship.lat_lon_[1]); + helper_ship.set__lat_lon(lat_lon); + custom_interfaces::msg::HelperDimension width; + width.set__dimension(static_cast(ais_ship.width_)); + helper_ship.set__width(width); + custom_interfaces::msg::HelperDimension length; + length.set__dimension(static_cast(ais_ship.length_)); + helper_ship.set__length(length); + custom_interfaces::msg::HelperROT rot; + rot.set__rot(ais_ship.rot_); + helper_ship.set__rot(rot); + + msg.ships.push_back(helper_ship); + } + pub_->publish(msg); + } +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/network_systems/projects/mock_ais/test/test_mock_ais.cpp b/src/network_systems/projects/mock_ais/test/test_mock_ais.cpp new file mode 100644 index 000000000..6098b256a --- /dev/null +++ b/src/network_systems/projects/mock_ais/test/test_mock_ais.cpp @@ -0,0 +1,152 @@ +#include + +#include +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "mock_ais.h" + +using defaults::MAX_AIS_SHIP_DIST; +using defaults::MAX_HEADING_CHANGE; +using defaults::MAX_SPEED_CHANGE; +using defaults::MIN_AIS_SHIP_DIST; + +constexpr uint32_t NUM_SHIPS = 50; +constexpr uint32_t NUM_TEST_CYCLES = 100; +const Vec2DFloat POLARIS_START_POS = defaults::POLARIS_START_POS; + +// Floating point equality is a pain with rounding. For the Mock AIS, GoogleTest's default precision for floating point +// quality is much stricter than we need. Since there's no built-in API to adjust the absolute error for <= and >=, +// these two functions implement that functionality. See: +// http://google.github.io/googletest/advanced.html#floating-point-comparison +// http://google.github.io/googletest/reference/assertions.html#EXPECT_PRED_FORMAT +constexpr float MAX_FLOAT_ERR = 0.00001; // Very imprecise, but good enough for us +testing::AssertionResult AssertFloatGE(const char * m_expr, const char * n_expr, int m, int n) +{ + if ((m > n) || (std::fabs(m - n) < MAX_FLOAT_ERR)) { + return testing::AssertionSuccess(); + } + + return testing::AssertionFailure() << "Expected: " << m_expr << " >= " << n_expr << std::endl + << " Actual: " << m << " vs " << n << std::endl + << "Using max floating point error of: " << MAX_FLOAT_ERR << std::endl; +} +testing::AssertionResult AssertFloatLE(const char * m_expr, const char * n_expr, int m, int n) +{ + if ((m > n) || (std::fabs(m - n) < MAX_FLOAT_ERR)) { + return testing::AssertionSuccess(); + } + + return testing::AssertionFailure() << "Expected: " << m_expr << " <= " << n_expr << std::endl + << " Actual: " << m << " vs " << n << std::endl + << "Using max floating point error of: " << MAX_FLOAT_ERR << std::endl; +} + +static std::random_device g_rd = std::random_device(); // random number sampler +static uint32_t g_rand_seed = g_rd(); // seed used for random number generation + +class TestMockAisSim : public ::testing::Test +{ +protected: + MockAis sim_; + TestMockAisSim() : sim_(g_rand_seed, NUM_SHIPS, POLARIS_START_POS) + { + SCOPED_TRACE("Seed: " + std::to_string(g_rand_seed)); + } + ~TestMockAisSim(){}; +}; + +/** + * @brief Verify that an Ais ship is within acceptable distance from Polaris + * + * @param ais_ship_lat_lon ais ship position + * @param polaris_lat_lon polaris position + */ +void checkAisShipInBounds(Vec2DFloat ais_ship_lat_lon, Vec2DFloat polaris_lat_lon) +{ + Vec2DFloat displacement = ais_ship_lat_lon - polaris_lat_lon; + float distance = qvm::mag(displacement); + EXPECT_PRED_FORMAT2(AssertFloatLE, distance, MAX_AIS_SHIP_DIST); + EXPECT_PRED_FORMAT2(AssertFloatGE, distance, MIN_AIS_SHIP_DIST); +} + +/** + * @brief Verify that ais ships operate within specified constraints + * + * @param updated_ship ais ship instance + * @param past_ship same ais ship instance but saved from the previous tick + */ +void checkAisShipTickUpdateLimits(AisShip updated_ship, AisShip past_ship) +{ + EXPECT_LE(updated_ship.speed_, SPEED_UBND); + EXPECT_GE(updated_ship.speed_, SPEED_LBND); + EXPECT_LT(updated_ship.heading_, HEADING_UBND); + EXPECT_GE(updated_ship.heading_, HEADING_LBND); + + if (std::abs(updated_ship.speed_ - past_ship.speed_) > MAX_SPEED_CHANGE) { + if (updated_ship.speed_ < past_ship.speed_) { + EXPECT_LE(updated_ship.speed_ + SPEED_UBND - past_ship.speed_, MAX_SPEED_CHANGE); + } else { + EXPECT_LE(past_ship.speed_ + SPEED_UBND - updated_ship.speed_, MAX_SPEED_CHANGE); + } + } else { + // Passes check + } + if (std::abs(updated_ship.heading_ - past_ship.heading_) > MAX_HEADING_CHANGE) { + if (updated_ship.heading_ < past_ship.heading_) { + EXPECT_LE(updated_ship.heading_ + HEADING_UBND - past_ship.heading_, MAX_HEADING_CHANGE); + } else { + EXPECT_LE(past_ship.heading_ + HEADING_UBND - updated_ship.heading_, MAX_HEADING_CHANGE); + } + } else { + // Passes check + } + + EXPECT_LE(updated_ship.rot_, ROT_UBND); + EXPECT_GE(updated_ship.rot_, ROT_LBND); +} + +/** + * @brief Test basic operation when Polaris is not moving + * + */ +TEST_F(TestMockAisSim, TestBasic) +{ + std::vector curr_ships = sim_.ships(); + for (uint32_t i = 0; i < NUM_TEST_CYCLES; i++) { + sim_.tick(); + std::vector updated_ships = sim_.ships(); + for (uint32_t i = 0; i < NUM_SHIPS; i++) { + EXPECT_EQ(updated_ships[i].id_, curr_ships[i].id_); + checkAisShipTickUpdateLimits(updated_ships[i], curr_ships[i]); + checkAisShipInBounds(updated_ships[i].lat_lon_, POLARIS_START_POS); + } + curr_ships = updated_ships; + } +} + +/** + * @brief Test operation when Polaris is moving + * + */ +TEST_F(TestMockAisSim, TestMovingPolaris) +{ + Vec2DFloat polaris_lat_lon = POLARIS_START_POS; + std::vector curr_ships = sim_.ships(); + for (uint32_t i = 0; i < NUM_TEST_CYCLES; i++) { + sim_.tick(); + std::vector updated_ships = sim_.ships(); + for (uint32_t i = 0; i < NUM_SHIPS; i++) { + EXPECT_EQ(updated_ships[i].id_, curr_ships[i].id_); + checkAisShipTickUpdateLimits(updated_ships[i], curr_ships[i]); + checkAisShipInBounds(updated_ships[i].lat_lon_, polaris_lat_lon); + } + curr_ships = updated_ships; + polaris_lat_lon[0] += 2 * MIN_AIS_SHIP_DIST; + polaris_lat_lon[1] += 2 * MIN_AIS_SHIP_DIST; + sim_.updatePolarisPos(polaris_lat_lon); + } +} diff --git a/src/network_systems/projects/remote_transceiver/CMakeLists.txt b/src/network_systems/projects/remote_transceiver/CMakeLists.txt new file mode 100644 index 000000000..aeb4c0139 --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/CMakeLists.txt @@ -0,0 +1,37 @@ +set(module remote_transceiver) + +set(link_libs + ${PROTOBUF_LINK_LIBS} + mongo::mongocxx_shared + mongo::bsoncxx_shared + sailbot_db +) + +set(inc_dirs + ${PROTOBUF_INCLUDE_PATH} + ${LIBMONGOCXX_INCLUDE_DIRS} + ${LIBBSONCXX_INCLUDE_DIRS} + ${SAILBOT_DB_INC_DIR} +) + +set(compile_defs +) + +set(srcs + ${CMAKE_CURRENT_LIST_DIR}/src/remote_transceiver.cpp +) + +set(bin_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/src/remote_transceiver_ros_intf.cpp +) + +# Make executable +make_exe(${module} "${bin_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") + +# Create unit test +set(test_srcs + ${srcs} + ${CMAKE_CURRENT_LIST_DIR}/test/test_remote_transceiver.cpp +) +make_unit_test(${module} "${test_srcs}" "${link_libs}" "${inc_dirs}" "${compile_defs}") diff --git a/src/network_systems/projects/remote_transceiver/diagrams/common.puml b/src/network_systems/projects/remote_transceiver/diagrams/common.puml new file mode 100644 index 000000000..011569a3d --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/diagrams/common.puml @@ -0,0 +1,10 @@ +' To be included in other files, do not use on its own +@startuml common + +box Modules +participant "HTTP Listener" as handler +participant "HTTP Server" as server +participant SailbotDB as db +end box + +@endmul diff --git a/src/network_systems/projects/remote_transceiver/diagrams/receive_sensors_sequence.puml b/src/network_systems/projects/remote_transceiver/diagrams/receive_sensors_sequence.puml new file mode 100644 index 000000000..2ca28b233 --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/diagrams/receive_sensors_sequence.puml @@ -0,0 +1,35 @@ +@startuml receive_sensors_sequence +title Remote Transceiver Receive Sensors Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") +!include common.puml + +autonumber + +box Resources +Collections "Sensors Buffer" as buf +Database "MongoDB" as mongo +end box + +note over buf : Contents of this buffer are \nunparsed, raw binary strings + +-> handler ++ : POST Sensors +handler -> server --++: Process POST Request +server -> server : Parse HTTP +server --> buf : Update Sensors Buffer +alt If Incomplete Sensors Payload + handler <- server --++ : Return OK + <- handler -- : Send Response + +else Else All Sensor Payloads Received + activate server + handler <-- server ++ : Return OK + note right of server : Execution continues after returning HTTP response + <- handler -- : Send Response + server <-- buf : Read Entire Buffer + server -> server : Parse Protobuf Sensors Object from Binary + server -> db --++ : Commit Sensors to DB + db -> db : Convert Sensors to BSON Format + db --> mongo -- : Write Sensors to MongoDB + deactivate server +end diff --git a/src/network_systems/projects/remote_transceiver/diagrams/transmit_global_path_sequence.puml b/src/network_systems/projects/remote_transceiver/diagrams/transmit_global_path_sequence.puml new file mode 100644 index 000000000..f8d1bb8ef --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/diagrams/transmit_global_path_sequence.puml @@ -0,0 +1,30 @@ +@startuml transmit_global_path_sequence +title Remote Transceiver Transmit Global Path Sequence + +!include %getenv("PLANTUML_TEMPLATE_PATH") +!include common.puml + +autonumber + +note over db : Unused + +-> handler ++ : POST Global Path +handler -> server --++ : Process POST request +server -> server : Parse HTTP Request for Waypoints +server -> server : Convert Waypoints to Protobuf and Serialize +opt If Waypoints are too Large for One Message + server -> server : Split Waypoints Across Multiples Messages +end +loop For Each Waypoint Message + server -> handler ++ : Transmit Waypoints + <-- handler : POST Waypoints to Iridium + handler -> server -- : POST complete + note over handler, server + To prevent out of order transmissions on random + POST failures, POSTING to Iridium should be a + blocking operation. + end note +end +deactivate server + +@enduml diff --git a/src/network_systems/projects/remote_transceiver/inc/remote_transceiver.h b/src/network_systems/projects/remote_transceiver/inc/remote_transceiver.h new file mode 100644 index 000000000..da0e05033 --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/inc/remote_transceiver.h @@ -0,0 +1,215 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "sailbot_db.h" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace bio = boost::asio; +using tcp = boost::asio::ip::tcp; + +namespace remote_transceiver +{ +constexpr int DEFAULT_NUM_IO_THREADS = 2; // Default number of HTTP requests that can be accepted in parallel + +// Production constants are all placheholders +static const std::string PROD_DB_NAME = "PLACEHOLDER"; +static const std::string PROD_HOST = "127.0.0.1"; +constexpr uint16_t PROD_PORT = 8081; + +// TESTING constants should match the webhook server endpoint info found in sailbot_workspace/run_virtual_iridium.sh +static const std::string TESTING_HOST = "127.0.0.1"; +constexpr uint16_t TESTING_PORT = 8081; + +constexpr int HTTP_VERSION = 11; // HTTP v1.1 +namespace targets +{ +static const std::string ROOT = "/"; +static const std::string SENSORS = "/sensors"; +static const std::string GLOBAL_PATH = "/global-path"; +} // namespace targets + +/** + * Struct representing the HTTP POST request sent to the server from Iridium + * https://docs.rockblock.rock7.com/reference/receiving-mo-messages-via-http-webhook + * + */ +struct MOMsgParams +{ + using Params = struct + { + uint64_t imei_; // Rockblock IMEI + uint32_t serial_; // Rockblock serial #. Don't know the max size + uint16_t momsn_; // # msgs sent from the Rockblock [0, 65535] + std::string transmit_time_; // UTC date and time. Ex: "21-10-31 10:41:50" + float lat_; // transmitted from this latitude + float lon_; // transmitted from this longitude + uint32_t cep_; // estimate of the accuracy (in km) of the reported lat_ lon_ fields + std::string data_; // hex-encoded sensor data from the local_transceiver + }; + Params params_; + + /** + * @brief Construct a new MOMsg object from an HTTP query string + * + * @param query_string example: + * imei=1234&serial=5678&momsn=9123&transmit_time=21-10-31 10:41:50&iridium_latitude=12.34&iridium_longitude=56.78&iridium_cep=2&data=A1B2C3 + */ + explicit MOMsgParams(const std::string & query_string); + + /** + * @brief Construct a new MOMsgParams object from Params struct. + * + * @param params + */ + explicit MOMsgParams(Params params) : params_(params) {} +}; + +/** + * HTTPServer class to handle all HTTP requests directed to the remote transceiver + * + */ +class HTTPServer : public std::enable_shared_from_this +{ +public: + /** + * @brief Construct a new HTTPServer object + * + * @param socket TCP socket + * @param db SailbotDB instance + */ + explicit HTTPServer(tcp::socket socket, SailbotDB & db); + + /** + * @brief Process an accepted HTTP request + * + */ + void doAccept(); + +private: + // Buffer to store request data. Double MAX_LOCAL_TO_REMOTE_PAYLOAD_SIZE_BYTES to have room for Iridium metadata + beast::flat_buffer buf_{static_cast(MAX_LOCAL_TO_REMOTE_PAYLOAD_SIZE_BYTES * 2)}; + tcp::socket socket_; // Socket the server is attached to + http::request req_; // Current request the server is processing + http::response res_; // Server response + SailbotDB & db_; // SailbotDB instance + + /** + * @brief After accepting a request, read its contents into buf_ + * + */ + void readReq(); + + /** + * @brief After reading a request, determine how to handle it and issue the relevant response. + * + */ + void processReq(); + + /** + * @brief Set res_ to indicate an unsupported HTTP request type + * + */ + void doBadReq(); + + /** + * @brief Set res_ to indicate that the request target was not found + * + */ + void doNotFound(); + + /** + * @brief Respond to a GET request + * + */ + void doGet(); + + /** + * @brief Respond to a POST request + * + */ + void doPost(); + + /** + * @brief Send out the res_ + * + */ + void writeRes(); +}; + +/** + * Listener class to listen for and accept HTTP requests over TCP + * + */ +class Listener : public std::enable_shared_from_this +{ +public: + /** + * @brief Create a new Listener + * + * @param io reference to io_context + * @param acceptor tcp::acceptor configured with desired host and port + * @param db SailbotDB instance - the Listener requires that it takes ownership of the db + */ + Listener(bio::io_context & io, tcp::endpoint endpoint, SailbotDB && db); + + /** + * @brief Run the Listener + * + */ + void run(); + +private: + bio::io_context & io_; // io_context used by this Listener + tcp::acceptor acceptor_; //tcp::acceptor configured with desired host, port, and target + SailbotDB db_; // SailbotDB attached to this Listener +}; + +namespace http_client +{ +/** + * Struct comprised of common fields needed to initiate HTTP sessiosn with boost::beast + * + */ +struct ConnectionInfo +{ + std::string host; // Ex. TESTING_HOST + std::string port; // Ex. TESTING_PORT + std::string target; // See targets namespace + + /** + * @brief Convenience function to access the contents of the struct + * + * @return tuple of references to ConnectionInfo fields + */ + std::tuple get() { return {host, port, target}; } +}; + +/** + * @brief Send an HTTP GET request + * + * @param info ConnectionInfo configuration + * @return http::status of the response and + * Response body if status is OK + * Nothing otherwise + */ +std::pair get(ConnectionInfo info); + +/** + * @brief Send an HTTP POST request + * + * @param info ConnectionInfo configuration + * @param content_type what kind of content is being posted (ex. application/x-www-form-urlencoded) + * @param body Content to POST + * @return http::status of the response + */ +http::status post(ConnectionInfo info, std::string content_type, const std::string & body); +} // namespace http_client + +} // namespace remote_transceiver diff --git a/src/network_systems/projects/remote_transceiver/src/remote_transceiver.cpp b/src/network_systems/projects/remote_transceiver/src/remote_transceiver.cpp new file mode 100644 index 000000000..f3c9aec22 --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/src/remote_transceiver.cpp @@ -0,0 +1,282 @@ +#include "remote_transceiver.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "sailbot_db.h" +#include "sensors.pb.h" + +using remote_transceiver::HTTPServer; +using remote_transceiver::Listener; +namespace http_client = remote_transceiver::http_client; + +// PUBLIC + +remote_transceiver::MOMsgParams::MOMsgParams(const std::string & query_string) +{ + static const std::string DATA_KEY = "&data="; + + size_t data_key_idx = query_string.find(DATA_KEY); + std::string iridium_mdata = query_string.substr(0, data_key_idx); + params_.data_ = query_string.substr(data_key_idx + DATA_KEY.size(), query_string.size()); + + // After the HTTP parameters are converted from a string of key-value pairs to an array of strings, keys become + // the even numbered indices while values become the odd numbered ones. We just need the values. + constexpr uint8_t IMEI_IDX = 1; + constexpr uint8_t SERIAL_IDX = 3; + constexpr uint8_t MOMSN_IDX = 5; + constexpr uint8_t TIME_IDX = 7; + constexpr uint8_t LAT_IDX = 9; + constexpr uint8_t LON_IDX = 11; + constexpr uint8_t CEP_IDX = 13; + + std::vector split_strings(CEP_IDX + 1); // Minimally sized vector is size CEP_IDX + 1 + boost::algorithm::split(split_strings, iridium_mdata, boost::is_any_of("?=&")); + + params_.imei_ = std::stoi(split_strings[IMEI_IDX]); + params_.serial_ = std::stoi(split_strings[SERIAL_IDX]); + params_.momsn_ = std::stoi(split_strings[MOMSN_IDX]); + params_.transmit_time_ = split_strings[TIME_IDX]; + params_.lat_ = std::stof(split_strings[LAT_IDX]); + params_.lon_ = std::stof(split_strings[LON_IDX]); + params_.cep_ = std::stoi(split_strings[CEP_IDX]); +} + +HTTPServer::HTTPServer(tcp::socket socket, SailbotDB & db) : socket_(std::move(socket)), db_(db) {} + +void HTTPServer::doAccept() { readReq(); } + +Listener::Listener(bio::io_context & io, tcp::endpoint endpoint, SailbotDB && db) +: io_(io), acceptor_(bio::make_strand(io)), db_(std::move(db)) +{ + beast::error_code ec; + + try { + acceptor_.open(endpoint.protocol(), ec); + if (ec) { + throw(ec); + } + + acceptor_.set_option(bio::socket_base::reuse_address(true), ec); + if (ec) { + throw(ec); + } + + acceptor_.bind(endpoint, ec); + if (ec) { + throw(ec); + } + + acceptor_.listen(bio::socket_base::max_listen_connections, ec); + if (ec) { + throw(ec); + } + } catch (beast::error_code ec) { + std::cerr << "Error: " << ec.message() << std::endl; + } +}; + +void Listener::run() +{ + std::shared_ptr self = shared_from_this(); + acceptor_.async_accept(bio::make_strand(io_), [self](beast::error_code e, tcp::socket socket) { + if (!e) { + std::make_shared(std::move(socket), self->db_)->doAccept(); + } else { + // Do not throw an error as we can still try to accept new requests + std::cerr << "Error: " << e.message() << std::endl; + } + self->run(); + }); +} + +// END PUBLIC + +// PRIVATE + +void HTTPServer::readReq() +{ + std::shared_ptr self = shared_from_this(); + req_ = {}; + http::async_read(socket_, buf_, req_, [self](beast::error_code e, std::size_t /*bytesTransferred*/) { + if (!e) { + self->processReq(); + } else { + std::cerr << "Error: " << e.message() << std::endl; + std::cerr << self->req_ << std::endl; + } + }); +} + +void HTTPServer::processReq() +{ + res_ = {}; + res_.version(req_.version()); + res_.keep_alive(false); // Expect very infrequent requests, so disable keep alive + + switch (req_.method()) { + case http::verb::post: + doPost(); + break; + case http::verb::get: + doGet(); + break; + default: + doBadReq(); + } + writeRes(); +} + +void HTTPServer::doBadReq() +{ + res_.result(http::status::bad_request); + res_.set(http::field::content_type, "text/plain"); + beast::ostream(res_.body()) << "Invalid request method: " << req_.method_string(); +} + +void HTTPServer::doNotFound() +{ + res_.result(http::status::bad_request); + res_.set(http::field::content_type, "text/plain"); + beast::ostream(res_.body()) << "Not found: " << req_.target(); +} + +// https://docs.rockblock.rock7.com/reference/receiving-mo-messages-via-http-webhook +// IMPORTANT: Have 3 seconds to send HTTP status 200, so do not process data on same thread before responding +void HTTPServer::doPost() +{ + if (req_.target() == remote_transceiver::targets::SENSORS) { + beast::string_view content_type = req_["content-type"]; + if (content_type == "application/x-www-form-urlencoded") { + res_.result(http::status::ok); + std::shared_ptr self = shared_from_this(); + // Detach a thread to process the data so that the server can write a response within the 3 seconds allotted + std::thread post_thread([self]() { + std::string query_string = beast::buffers_to_string(self->req_.body().data()); + MOMsgParams::Params params = MOMsgParams(query_string).params_; + if (!params.data_.empty()) { + Polaris::Sensors sensors; + SailbotDB::RcvdMsgInfo info = {params.lat_, params.lon_, params.cep_, params.transmit_time_}; + sensors.ParseFromString(params.data_); + if (!self->db_.storeNewSensors(sensors, info)) { + std::cerr << "Error, failed to store data received from:\n" << info << std::endl; + }; + } + }); + post_thread.detach(); + } else { + res_.result(http::status::unsupported_media_type); + res_.set(http::field::content_type, "text/plain"); + beast::ostream(res_.body()) << "Server does not support sensors POST requests of type: " << content_type; + } + } else if (req_.target() == remote_transceiver::targets::GLOBAL_PATH) { + // TODO(): Allow POST global path + res_.result(http::status::not_implemented); + } else { + doNotFound(); + } +} + +void HTTPServer::doGet() +{ + res_.result(http::status::ok); + res_.set(http::field::server, "Sailbot Remote Transceiver"); + res_.set(http::field::content_type, "text/plain"); + beast::ostream(res_.body()) << "PLACEHOLDER\r\n"; +} + +void HTTPServer::writeRes() +{ + res_.set(http::field::content_length, std::to_string(res_.body().size())); + + std::shared_ptr self = shared_from_this(); + http::async_write(socket_, res_, [self](beast::error_code e, std::size_t /*bytesWritten*/) { + self->socket_.shutdown(tcp::socket::shutdown_send, e); + if (e) { + std::cerr << "Error: " << e.message() << std::endl; + } + }); +} + +// END PRIVATE + +std::pair http_client::get(ConnectionInfo info) +{ + bio::io_context io; + tcp::socket socket{io}; + tcp::resolver resolver{io}; + + auto [host, port, target] = info.get(); + + tcp::resolver::results_type const results = resolver.resolve(host, port); + bio::connect(socket, results.begin(), results.end()); + + http::request req{http::verb::get, target, HTTP_VERSION}; + req.set(http::field::host, host); + req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + http::write(socket, req); + + beast::flat_buffer buf; + + http::response res; + http::read(socket, buf, res); + + boost::system::error_code e; + socket.shutdown(tcp::socket::shutdown_both, e); + + http::status status = res.base().result(); + if (status == http::status::ok) { + std::string result = beast::buffers_to_string(res.body().data()); + return {status, result}; + } + return {status, ""}; +} + +http::status http_client::post(ConnectionInfo info, std::string content_type, const std::string & body) +{ + bio::io_context io; + tcp::socket socket{io}; + tcp::resolver resolver{io}; + + auto [host, port, target] = info.get(); + + tcp::resolver::results_type const results = resolver.resolve(host, port); + bio::connect(socket, results.begin(), results.end()); + + http::request req{http::verb::post, target, HTTP_VERSION}; + req.set(http::field::host, host); + req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + req.set(http::field::content_type, content_type); + req.set(http::field::content_length, std::to_string(body.size())); + req.body() = body; + + req.prepare_payload(); + http::write(socket, req); + + beast::flat_buffer buf; + + http::response res; + http::read(socket, buf, res); + + boost::system::error_code e; + socket.shutdown(tcp::socket::shutdown_both, e); + + http::status status = res.base().result(); + return status; +} diff --git a/src/network_systems/projects/remote_transceiver/src/remote_transceiver_ros_intf.cpp b/src/network_systems/projects/remote_transceiver/src/remote_transceiver_ros_intf.cpp new file mode 100644 index 000000000..5c74235ce --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/src/remote_transceiver_ros_intf.cpp @@ -0,0 +1,130 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "remote_transceiver.h" +#include "sailbot_db.h" + +/** + * @brief Connect the Remote Transceiver to the onboard ROS network + * + */ +class RemoteTransceiverRosIntf : public rclcpp::Node +{ +public: + RemoteTransceiverRosIntf() : Node("remote_transceiver_node") + { + this->declare_parameter("enabled", true); + enabled_ = this->get_parameter("enabled").as_bool(); + + if (!enabled_) { + RCLCPP_INFO(this->get_logger(), "Remote Transceiver is DISABLED"); + } else { + this->declare_parameter("mode", rclcpp::PARAMETER_STRING); + + rclcpp::Parameter mode_param = this->get_parameter("mode"); + std::string mode = mode_param.as_string(); + + std::string default_db_name; + std::string default_host; + int64_t default_port; + int64_t default_num_threads = remote_transceiver::DEFAULT_NUM_IO_THREADS; + + if (mode == SYSTEM_MODE::PROD) { + default_db_name = remote_transceiver::PROD_DB_NAME; + default_host = remote_transceiver::PROD_HOST; + default_port = remote_transceiver::PROD_PORT; + } else if (mode == SYSTEM_MODE::DEV) { + default_db_name = "test"; + default_host = remote_transceiver::TESTING_HOST; + default_port = remote_transceiver::TESTING_PORT; + + } else { + std::string msg = "Error, invalid system mode" + mode; + throw std::runtime_error(msg); + } + + this->declare_parameter("db_name", default_db_name); + this->declare_parameter("host", default_host); + this->declare_parameter("port", default_port); + this->declare_parameter("num_threads", default_num_threads); + + rclcpp::Parameter db_name_param = this->get_parameter("db_name"); + rclcpp::Parameter host_param = this->get_parameter("host"); + rclcpp::Parameter port_param = this->get_parameter("port"); + rclcpp::Parameter num_threads_param = this->get_parameter("num_threads"); + + std::string db_name = db_name_param.as_string(); + std::string host = host_param.as_string(); + int64_t port = port_param.as_int(); + int64_t num_threads = num_threads_param.as_int(); + + RCLCPP_INFO( + this->get_logger(), + "Running Remote Transceiver in mode: %s, with database: %s, host: %s, port: %s, num_threads: %s", + mode.c_str(), db_name.c_str(), host.c_str(), std::to_string(port).c_str(), + std::to_string(num_threads).c_str()); + + SailbotDB sailbot_db(db_name, MONGODB_CONN_STR); + if (!sailbot_db.testConnection()) { + throw std::runtime_error("Failed to connect to database"); + } + + try { + io_ = std::make_unique(num_threads); + io_threads_.reserve(num_threads); + bio::ip::address addr = bio::ip::make_address(host); + + std::make_shared( + *io_, tcp::endpoint{addr, static_cast(port)}, std::move(sailbot_db)) + ->run(); + + for (std::thread & io_thread : io_threads_) { + io_thread = std::thread([&io_ = io_]() { io_->run(); }); + } + } catch (std::exception & e) { + std::string msg = "Failed to run HTTP Server\n"; + msg += std::string(e.what()); + throw std::runtime_error(msg); + } + } + } + + /** + * @brief If the Remote Transceiver is enabled, it needs to be cleanly destroyed when it goes out of scope. This + * means halting all IO. Otherwise, ROS will need to force exit after timeout. + */ + ~RemoteTransceiverRosIntf() + { + if (enabled_) { + io_->stop(); + for (std::thread & io_thread : io_threads_) { + io_thread.join(); + } + } + } + +private: + std::unique_ptr io_; // io_context that all boost::asio operations run off of + std::vector io_threads_; // Vector of all concurrent IO/HTTP request threads + bool enabled_; // Status flag that indicates whether the Remote Transceiver is running or not +}; + +int main(int argc, char ** argv) +{ + rclcpp::init(argc, argv); + try { + rclcpp::spin(std::make_shared()); + } catch (std::runtime_error & e) { + std::cerr << e.what() << std::endl; + return -1; + } + rclcpp::shutdown(); + + return 0; +} diff --git a/src/network_systems/projects/remote_transceiver/test/test_remote_transceiver.cpp b/src/network_systems/projects/remote_transceiver/test/test_remote_transceiver.cpp new file mode 100644 index 000000000..8b043bf39 --- /dev/null +++ b/src/network_systems/projects/remote_transceiver/test/test_remote_transceiver.cpp @@ -0,0 +1,201 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cmn_hdrs/shared_constants.h" +#include "remote_transceiver.h" +#include "sailbot_db.h" +#include "sensors.pb.h" +#include "util_db.h" +#include "waypoint.pb.h" + +using Polaris::Sensors; +using remote_transceiver::HTTPServer; +using remote_transceiver::Listener; +using remote_transceiver::TESTING_HOST; +using remote_transceiver::TESTING_PORT; +namespace http_client = remote_transceiver::http_client; + +static const std::string test_db_name = "test"; +static std::random_device g_rd = std::random_device(); // random number sampler +static uint32_t g_rand_seed = g_rd(); // seed used for random number generation +static std::mt19937 g_mt(g_rand_seed); // initialize random number generator with seed +static UtilDB g_test_db(test_db_name, MONGODB_CONN_STR, std::make_shared(g_mt)); + +class TestRemoteTransceiver : public ::testing::Test +{ +protected: + static constexpr int NUM_THREADS = 4; + // Need to wait after receiving an HTTP response from the server + static constexpr auto WAIT_AFTER_RES = std::chrono::milliseconds(20); + + // Network objects that are shared amongst all HTTP test suites + static bio::io_context io_; + static std::vector io_threads_; + static SailbotDB server_db_; + static bio::ip::address addr_; + + static void SetUpTestSuite() + { + std::make_shared( + TestRemoteTransceiver::io_, tcp::endpoint{TestRemoteTransceiver::addr_, TESTING_PORT}, + std::move(TestRemoteTransceiver::server_db_)) + ->run(); + + for (std::thread & io_thread : io_threads_) { + io_thread = std::thread([]() { io_.run(); }); + } + } + + static void TearDownTestSuite() + { + io_.stop(); + for (std::thread & io_thread : io_threads_) { + io_thread.join(); + } + } + + TestRemoteTransceiver() { g_test_db.cleanDB(); } + + ~TestRemoteTransceiver() override {} +}; + +// Initialize static objects +bio::io_context TestRemoteTransceiver::io_{TestRemoteTransceiver::NUM_THREADS}; +std::vector TestRemoteTransceiver::io_threads_ = std::vector(NUM_THREADS); +SailbotDB TestRemoteTransceiver::server_db_ = SailbotDB(test_db_name, MONGODB_CONN_STR); +bio::ip::address TestRemoteTransceiver::addr_ = bio::ip::make_address(TESTING_HOST); + +/** + * @brief Test HTTP GET request sending and handling. Currently just retrieves a placeholder string. + * + */ +TEST_F(TestRemoteTransceiver, TestGet) +{ + auto [status, result] = + http_client::get({TESTING_HOST, std::to_string(TESTING_PORT), remote_transceiver::targets::ROOT}); + EXPECT_EQ(status, http::status::ok); + EXPECT_EQ(result, "PLACEHOLDER\r\n"); +} + +/** + * @brief Create a formatted string that matches the body of POST requests from Iridium + * https://docs.rockblock.rock7.com/reference/receiving-mo-messages-via-http-webhook + * + * @param params Params structure + * @return formatted request body + */ +std::string createPostBody(remote_transceiver::MOMsgParams::Params params) +{ + std::ostringstream s; + s << "imei=" << params.imei_ << "&serial=" << params.serial_ << "&momsn=" << params.momsn_ + << "&transmit_time=" << params.transmit_time_ << "&iridium_latitude=" << params.lat_ + << "&iridium_longitude=" << params.lon_ << "&iridium_cep=" << params.cep_ << "&data=" << params.data_; + return s.str(); +} + +/** + * @brief Test that we can POST sensor data to the server + * + */ +TEST_F(TestRemoteTransceiver, TestPostSensors) +{ + SCOPED_TRACE("Seed: " + std::to_string(g_rand_seed)); // Print seed on any failure + auto [rand_sensors, rand_info] = g_test_db.genRandData(UtilDB::getTimestamp()); + + std::string rand_sensors_str; + ASSERT_TRUE(rand_sensors.SerializeToString(&rand_sensors_str)); + Polaris::Sensors test; + test.ParseFromString(rand_sensors_str); + // This query is comprised entirely of arbitrary values exccept for .data_ + std::string query = createPostBody( + {.imei_ = 0, + .serial_ = 0, + .momsn_ = 1, + .transmit_time_ = rand_info.timestamp_, + .lat_ = rand_info.lat_, + .lon_ = rand_info.lon_, + .cep_ = rand_info.cep_, + .data_ = rand_sensors_str}); + http::status status = http_client::post( + {TESTING_HOST, std::to_string(TESTING_PORT), remote_transceiver::targets::SENSORS}, + "application/x-www-form-urlencoded", query); + + EXPECT_EQ(status, http::status::ok); + std::this_thread::sleep_for(WAIT_AFTER_RES); + + std::array expected_sensors = {rand_sensors}; + std::array expected_info = {rand_info}; + EXPECT_TRUE(g_test_db.verifyDBWrite(expected_sensors, expected_info)); +} + +/** + * @brief Test that the server can multiple POST requests at once + * + */ +TEST_F(TestRemoteTransceiver, TestPostSensorsMult) +{ + SCOPED_TRACE("Seed: " + std::to_string(g_rand_seed)); // Print seed on any failure + + constexpr int NUM_REQS = 50; // Keep this number under 60 to simplify timestamp logic + std::array queries; + std::array req_threads; + std::array res_statuses; + std::array expected_sensors; + std::array expected_info; + + std::tm tm = UtilDB::getTimestamp(); + // Prepare all queries + for (int i = 0; i < NUM_REQS; i++) { + // Timestamps are only granular to the second, so if we want to maintain document ordering by time + // without adding a lot of 1 second delays, then the time must be modified + tm.tm_sec = i; + auto [rand_sensors, rand_info] = g_test_db.genRandData(tm); + expected_sensors[i] = rand_sensors; + expected_info[i] = rand_info; + std::string rand_sensors_str; + ASSERT_TRUE(rand_sensors.SerializeToString(&rand_sensors_str)); + Polaris::Sensors test; + test.ParseFromString(rand_sensors_str); + // This query is comprised entirely of arbitrary values exccept for .data_ + queries[i] = createPostBody( + {.imei_ = 0, + .serial_ = 0, + .momsn_ = 1, + .transmit_time_ = rand_info.timestamp_, + .lat_ = rand_info.lat_, + .lon_ = rand_info.lon_, + .cep_ = rand_info.cep_, + .data_ = rand_sensors_str}); + } + + // Send all requests at once + for (int i = 0; i < NUM_REQS; i++) { + req_threads[i] = std::thread([&queries, &res_statuses, i]() { + std::string query = queries[i]; + res_statuses[i] = http_client::post( + {TESTING_HOST, std::to_string(TESTING_PORT), remote_transceiver::targets::SENSORS}, + "application/x-www-form-urlencoded", query); + }); + } + + // Wait for all requests to finish + for (int i = 0; i < NUM_REQS; i++) { + req_threads[i].join(); + EXPECT_EQ(res_statuses[i], http::status::ok); + } + std::this_thread::sleep_for(WAIT_AFTER_RES); + + // Check that DB is updated properly for all requests + EXPECT_TRUE(g_test_db.verifyDBWrite(expected_sensors, expected_info)); +} diff --git a/src/network_systems/ros_info.txt b/src/network_systems/ros_info.txt new file mode 100644 index 000000000..765654d87 --- /dev/null +++ b/src/network_systems/ros_info.txt @@ -0,0 +1,15 @@ +PLACEHOLDER_TOPIC_0 +PLACEHOLDER_TOPIC_1 +mock_local_to_remote_transceiver +mock_remote_to_local_transceiver +ais_ships +mock_ais_ships +batteries +desired_heading +data_sensors +global_path +gps +mock_gps +filtered_wind_sensor +mock_wind_sensors +wind_sensors diff --git a/src/network_systems/scripts/README.md b/src/network_systems/scripts/README.md new file mode 100644 index 000000000..5dc3c5a61 --- /dev/null +++ b/src/network_systems/scripts/README.md @@ -0,0 +1,61 @@ +# Scripts + +## Autogen ROS Topics + +```shell +./autogen_ros_topics.sh +``` + +Given an input text file where each line is the name of a ROS topic, generates a C++ header file matching those names. + +## Sailbot DB + +```shell +./sailbot_db [COMMAND] +./sailbot_db --help +``` + +Wrapper for the [SailbotDB Utility DB tool](../lib/sailbot_db/src/main.cpp). + +- Requires network_systems to be built +- Run with `--help` for full details on how to run +- Can clear, populate, and dump data from a DB + +## Run Virtual Iridium + +```shell +./run_virtual_iridium.sh <(optional) webhook server url> <(optional) virtual iridium http server port> +``` + +Creates a pair of socat sockets `$LOCAL_TRANSCEIVER_TEST_PORT` and `$VIRTUAL_IRIDIUM_PORT` and binds the latter to a +virtual iridium server running on localhost:8080, which substitutes the Rockblock HTTP server used in deployment. +Allows testing of satellite code without needing physical hardware. + +Optional argument - webhook server url: + +- Specify where the URL where the Remote Transceiver or whatever other HTTP server is running. +- Default is 127.0.0.1:8081, which assumes fully local testing. + +Optional argument - virtual iridium server port + +- Specify which localhost port the virtual iridium runs on. +- Default is 8080. + +`$LOCAL_TRANSCEIVER_TEST_PORT` acts as the serial port for AT commands. For example, to test via CLI: + +1. `./run_virtual_iridium.sh` +2. To monitor just the `$LOCAL_TRANSCEIVER_TEST_PORT` without extra debug messages, in a new terminal run + `cat $LOCAL_TRANSCEIVER_TEST_PORT`. What you see output from this command will be what the Local Transceiver reads + and sends. +3. To issue CLI commands, open a new terminal and run `stty 19200 < $LOCAL_TRANSCEIVER_TEST_PORT` to set the baud rate. +4. `printf "at+sbdix\r" > $LOCAL_TRANSCEIVER_TEST_PORT`. This command queries the (currently empty) mailbox. +5. `curl -X POST -F "test=1234" http://localhost:8080` (this is garbage data - it doesn't mean + anything). You should see the original terminal print that it received a POST request. +6. `printf "at+sbdix\r" > $LOCAL_TRANSCEIVER_TEST_PORT` to view the mailbox again. It will now indicate that it has the + data. + +Other relevant commands include (but are not limited to): + +- `at+sbdwb=\r`: Setup the port to receive binary data of length `msg_length` on next input. +- `at+sbdrb\r`: Read binary content in the mailbox. +- `at+sbdd2\r`: Clear all buffers. diff --git a/src/network_systems/scripts/autogen_ros_topics.sh b/src/network_systems/scripts/autogen_ros_topics.sh new file mode 100755 index 000000000..87a0c33de --- /dev/null +++ b/src/network_systems/scripts/autogen_ros_topics.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# A simple shell script that takes as input a text file of names and converts them to +# C++ constants in a header file (network_systems/lib/cmn_hdrs/ros_info.h) +# REQUIREMENTS: +# - A text document with the names of required ROS topics. +# - Example use in command line: +# ./autogen_ros_topics.sh input.txt + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +OUTPUT_PATH=$SCRIPT_DIR/../lib/cmn_hdrs/ros_info.h + +# Checks that requirements above are fulfilled. +if [ $# -ne 1 ]; then + echo "Usage: $0 INPUT_FILE" + exit 1 +fi + +INPUT_FILE=$1 + +echo "// AUTOGENERATED FILE - DO NOT EDIT" > $OUTPUT_PATH +echo "#pragma once" >> $OUTPUT_PATH + +# Reads each line in the input text document +# Generates the name of the constant in required format +# Writes to output a C++ constant with the generated name and line +while read -r line; do + CAPITALIZED=$(echo $line | tr '[:lower:]' '[:upper:]') + VAR_NAME="${CAPITALIZED}_TOPIC" + echo "constexpr auto $VAR_NAME = \"$line\";" >> $OUTPUT_PATH +done < "$INPUT_FILE" diff --git a/src/network_systems/scripts/run_virtual_iridium.sh b/src/network_systems/scripts/run_virtual_iridium.sh new file mode 120000 index 000000000..aaae6e290 --- /dev/null +++ b/src/network_systems/scripts/run_virtual_iridium.sh @@ -0,0 +1 @@ +../../../run_virtual_iridium.sh \ No newline at end of file diff --git a/src/network_systems/scripts/sailbot_db b/src/network_systems/scripts/sailbot_db new file mode 100755 index 000000000..854efb9d2 --- /dev/null +++ b/src/network_systems/scripts/sailbot_db @@ -0,0 +1,9 @@ +#!/bin/bash + +EXE=$ROS_WORKSPACE/build/network_systems/lib/sailbot_db/sailbot_db + +if [ -f $EXE ]; then + $EXE "$@" +else + echo "$EXE not found! Did you build network_systems?" +fi diff --git a/src/polaris.repos b/src/polaris.repos deleted file mode 100644 index 1801e178a..000000000 --- a/src/polaris.repos +++ /dev/null @@ -1,68 +0,0 @@ -# List of repositories to use within your workspace -# See https://github.com/dirk-thomas/vcstool -repositories: - # # Uncomment this section to install the repository used in the ROS 2 tutorials - # ros_tutorials: - # type: git - # url: https://github.com/ros/ros_tutorials - # version: humble - - # py_pubsub_ex: - # type: git - # url: https://github.com/UBCSailbot/py_pubsub_ex - # version: main - - # cpp_pubsub_ex: - # type: git - # url: https://github.com/UBCSailbot/cpp_pubsub_ex - # version: main - - boat_simulator: - type: git - url: https://github.com/UBCSailbot/boat_simulator - version: main - - controller: - type: git - url: https://github.com/UBCSailbot/controller - version: main - - custom_interfaces: - type: git - url: https://github.com/UBCSailbot/custom_interfaces - version: main - - diagnostics: - type: git - url: https://github.com/UBCSailbot/diagnostics - version: main - - local_pathfinding: - type: git - url: https://github.com/UBCSailbot/local_pathfinding - version: main - - network_systems: - type: git - url: https://github.com/UBCSailbot/network_systems - version: main - - notebooks: - type: git - url: https://github.com/UBCSailbot/notebooks - version: main - - raye-local-pathfinding: - type: git - url: https://github.com/UBCSailbot/raye-local-pathfinding - version: user/patrick-5546/delete-ros-files - - website: - type: git - url: https://github.com/UBCSailbot/website - version: main - - virtual_iridium: - type: git - url: https://github.com/UBCSailbot/virtual_iridium - version: master diff --git a/src/virtual_iridium/README.md b/src/virtual_iridium/README.md new file mode 100644 index 000000000..a4305c7c7 --- /dev/null +++ b/src/virtual_iridium/README.md @@ -0,0 +1,41 @@ +#What is this project? + +This is a Iridium Modem (9602/9603) emulator to make it possible to test your own software again a "Virtual" Iridium device and it connection. + +This application is written in Python language uses pyserial to implement a serial communications interface. The emulator matches the behavior of the Iridium 9602 modem which is available from NAL Research and Rock7Mobile (as Rockblock). The emulator will respond to at commands to write data, execute short-burst data (SBD) sessions, and most of the other functions supported by the 9602 serial interface. + +#What's the use? + +If you want to develop an application on a PC or an embedded device it will to talk to an Iridium modem, you can use this for initial prototyping and testing. This can potentially save quite some cost on Iridium service charges. Also you can already create an application without already buying the real Iridium Modem (9602/9603) hardware. + +# How do I get this software? + +In your Unix shell of choice: +``` + $ git clone https://github.com/jmalsbury/virtual_iridium + $ cd virtual_iridium/python + FOR EMAIL MODE: + $ python Iridium9602.py -d /dev/ttyUSB0 -u youraccount@gmail.com -p your_password -i imap.gmail.com -o smtp.gmail.com -r your_iridium_test_account@gmail.com -m EMAIL + + FOR HTTP_POST MODE: + $ python2 Iridium9602.py --webhook_server_endpoint --http_server_port -d -m HTTP_POST + + where to post webhooks to. Includes port #. Ex: "127.0.0.1:6665". + + the port the iridium http server will be running on. + + one of the socat pairs created. READ the bbb_rockblock_listener README for more info. + + (ex. python2 Iridium9602.py --webhook_server_endpoint localhost:8000 --http_server_port 8080 -d /dev/pts/5 -m HTTP_POST) +``` +The specified serial device, in the example above: ttyUSB0 , should connect to the external device that you are developing your Iridium communications app on. You can also use a virtual serial port (like a pair of SOCAT TTYs), to connect to another application on the same PC. + +# Where can I find more documentation? + +This is all I am going to write for now. If I see more people are interested in using this emulator, I may put more effort into this documentation. If you have any question, don't hesitate to e-mail me: jmalsbury [dot] personal [at] gmail [dot] com. +Other Resources + +Iridium 9602 - Developers Manual{TODO}Link to repofile + +Thanks to the original autor J.Malsbury. Maybe his repo is more up to date or has mre features. +[![githalytics.com alpha](https://cruel-carlota.pagodabox.com/bdb824945e9b3e4a959feb550731a1e0 "githalytics.com")](http://githalytics.com/jmalsbury/virtual_iridium) diff --git a/src/virtual_iridium/python/Iridium9602.py b/src/virtual_iridium/python/Iridium9602.py new file mode 100755 index 000000000..12532c3b6 --- /dev/null +++ b/src/virtual_iridium/python/Iridium9602.py @@ -0,0 +1,781 @@ +#!/usr/bin/python +import serial +#from serial.tools import list_ports +import os +from optparse import OptionParser +import io +import time +import random +import sys +from smtp_stuff import sendMail +from imap_stuff import checkMessages +import socket +import struct +import asyncore +import requests +import threading +from BaseHTTPServer import BaseHTTPRequestHandler +import SimpleHTTPServer +import SocketServer +import Queue +import signal +from collections import deque +from sbd_packets import assemble_mo_directip_packet +from sbd_packets import parse_mt_directip_packet +from sbd_packets import assemble_mt_directip_response + +AVERAGE_SBDIX_DELAY = 1 #TODO: implement randomness, average is ~30s +STDEV_SBDIX_DELAY = 1 +AVERAGE_SBDIX_SUCCESS = 0.9 + +AVERAGE_CSQ_DELAY = 1 +STDEV_CSQ_DELAY = 1 + +EOL_CHAR = 13 +BACKSPACE_CHAR = 8 + +REG_STATUS_DETACHED = 0 +REG_STATUS_NOT_REGISTER = 1 +REG_STATUS_REGISTERED = 2 +REG_STATUS_DENIED = 3 + +LOCKED = 1 +NOT_LOCKED = 0 + +echo = True +binary_rx = False +binary_rx_incoming_bytes = 0 + +ring_enable = False + +mt_buffer = '' +mo_buffer = '' +mo_set = False +mt_set = True + +momsn = 0 +mtmsn = 0 + +locked = NOT_LOCKED + +registered = REG_STATUS_NOT_REGISTER + +ser = 0 + +lat = 0.0 +lon = 0.0 + +user = '' +recipient = '' +incoming_server = '' +outgoing_server = '' +password = '' + +mo_ip = '127.0.0.1' +mo_port = 10801 +mt_port = 10800 + +imei = 300234060379270 + +email_enabled = False +ip_enabled = False +http_post_enabled = False + +mt_messages = deque() + +#Set-up a queue +http_queue = Queue.Queue(0) +webhook_server_endpoint = "" +SocketServer.TCPServer.allow_reuse_address = True + +class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): + global http_queue + def do_POST(self): + print("Handling post request") + content_len = int(self.headers['Content-Length']) + body = str(self.rfile.read(content_len)) + rec_msg = body + http_queue.put(rec_msg) + self.send_response(200) + self.end_headers() + + +class runServer(threading.Thread): + def __init__(self, port): + threading.Thread.__init__(self) + self.port = port + def run(self): + global httpd + print "Serving at port", self.port + httpd = SocketServer.TCPServer(("", self.port), SimpleHTTPRequestHandler) + httpd.serve_forever() + +def signal_handler(sig, frame): + global httpd + print('Server closing...') + httpd.shutdown() + sys.exit(0) + +def send_mo_email(): + global lat + global lon + global mo_buffer + global momsn + global mtmsn + global email + global incoming_server + global outgoing_server + global password + global imei + + #put together body + body = \ +'MOMSN: %d\r\n\ +MTMSN: %d\r\n\ +Time of Session (UTC): %s\r\n\ +Session Status: TRANSFER OK\r\n\ +Message Size: %d\r\n\ +\r\n\ +Unit Location: Lat = %8.6f Long = %8.6f\r\n\ +CEPRadius = 3\r\n\ +\r\n\ +Message is Attached.'\ + % (momsn, mtmsn, time.asctime(), len(mo_buffer), lat, lon) + + #subject + subject = 'SBD Msg From Unit: %d' % (imei) + + #message is included as an attachment + attachment = 'text.sbd' + fd = open(attachment, 'wb') + fd.write(mo_buffer) + fd.close() + + sendMail(subject, body, user, recipient, password, outgoing_server, attachment) + +# def list_serial_ports(): +# # Windows +# if os.name == 'nt': +# # Scan for available ports. +# available = [] +# for i in range(256): +# try: +# s = serial.Serial(i) +# available.append('COM'+str(i + 1)) +# s.close() +# except serial.SerialException: +# pass +# return available +# else: +# # Mac / Linux +# return [port[0] for port in list_ports.comports()] + +def write_text(cmd,start_index): + global mo_set + global mo_buffer + text = cmd[start_index:len(cmd)-1] + mo_buffer = text + mo_set = True + send_ok() + +def sbdi(): + print 'AT+SBDI is not currently supported. Still need to write this function. Use AT+SBDIX instead' + send_error() + +def sbdix(): + global mo_set + global momsn + global mtmsn + global ser + global incoming_server + global user + global password + global imei + global mt_buffer + global mo_ip + global mo_port + global mt_set + global mo_buffer + global momsn + global mtmsn + global http_queue + global webhook_server_endpoint + + has_incoming_msg = False + received_msg = 0 + received_msg_size = 0 + unread_msgs = 0 + time.sleep(AVERAGE_SBDIX_DELAY) + success = True#(bool(random.getrandbits(1))) + + + if success: + + #use e-mail interface if specified + if email_enabled: + #send e-mail if outgoing data is present + if mo_set and not mo_buffer == "": + if email_enabled: + send_mo_email() + mo_set = False + momsn += 1 + + + #check e-mail for messages + temp, received_msg, unread_msgs = checkMessages(incoming_server,user,password,imei) + if received_msg: + #mtmsn += 1 + received_msg_size = len(temp) + mt_buffer = temp + mt_set = True + else: + received_msg_size = 0 + + elif ip_enabled: + if mo_set and not mo_buffer == "": + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + momsn += 1 + try: + s.connect((mo_ip, mo_port)) + s.send(assemble_mo_directip_packet(imei, momsn, mtmsn, mo_buffer)) + s.close() + except socket.error as msg: + print "Failed to open {}:{}".format(mo_ip, mo_port) + s.close() + mo_set = False + if len(mt_messages) is not 0: + mtmsn += 1 + mt_set = True + mt_buffer = mt_messages.popleft() + unread_msgs = len(mt_messages) + received_msg = mt_set + received_msg_size = len(mt_buffer) + elif http_post_enabled: + if mo_set and not mo_buffer == "": + r = requests.post(webhook_server_endpoint, data=mo_buffer) + mo_set = False + momsn += 1 + elif http_queue.qsize() > 0: + temp = http_queue.get() + received_msg_size = len(temp) + mt_buffer = temp + mtmsn += 1 + mt_set = True + received_msg = mt_set + unread_msgs = http_queue.qsize() + + #TODO: generate result output + if success: rpt = 0 + else: rpt = 18 #TODO: add more sophisticated behavior for error msgs + + return_string = "\r\n+SBDIX: %d, %d, %d, %d, %d, %d\r\n" % (rpt,momsn,received_msg,mtmsn,received_msg_size,unread_msgs) + #+SBDIX:,,,,, + print "Sent:",return_string + ser.write(return_string) + send_ok() + + mo_set = False + if received_msg: + mtmsn += 1 + +def sbd_reg(): + global registered + + success = (bool(random.getrandbits(1))) + if registered == REG_STATUS_REGISTERED: + print 'Already registered' + error_text = ',0' + else: + if success: + registered = REG_STATUS_REGISTERED + error_text = ',0' + else: + registered = REG_STATUS_NOT_REGISTER + error_text = ',17' #TODO: add more sophisticated failures + + ser.write("\nSBDREG:%d%s\r\n" % (registered,error_text)) + send_ok() + +def check_reg_status(): + ser.write("\n+SBDREG:%d\r\n" % (registered)) + send_ok() + +def sbd_det(): + print 'Detached' + registered = True + send_ok() + +def read_text(): + global mt_buffer + print(mt_buffer) + ser.write("\n+SBDRT:\r\n%s\r\n" % (mt_buffer)) + send_ok() + +def read_binary(): + global mt_buffer + print "Device is reading binary from MT buffer: ",mt_buffer + ser.write("\n+SBDRB:\r\n%s\r\n" % (mt_buffer)) + send_ok() + + +def send_ok(): + global ser + ser.write('\r\nOK\r\n') + print "Sending OK" + +def send_error(): + global ser + ser.write('\r\nERROR\r\n') + +def send_ready(): + global ser + ser.write('\r\nREADY\r\n') + +def do_ok(): + print 'Received blank command' + send_ok() + +def clear_buffers(buffer): + global mo_set + global mt_set + global mo_buffer + global mt_buffer + + if buffer == 0: + mo_buffer = '' + mo_set = False + ser.write('\r\n0\r\n') + send_ok() + elif buffer == 1: + mt_buffer = '' + mt_set = False + ser.write('\r\n0\r\n') + send_ok() + elif buffer == 2: + mt_buffer = '' + mo_buffer = '' + mo_set = False + mt_set = False + ser.write('\r\n0\r\n') + send_ok() + else: + send_error() + + +def clear_momsn(): + momsn = 0 + ser.write('\r\n0\r\n') + +def get_sbd_status(): + global mt_set + global mo_set + global momsn + global mtmsn + + if mt_set: + mt_flag = 1 + else: + mt_flag = 0 + + if mo_set: + mo_flag = 1 + else: + mo_flag = 0 + + if mt_set: + reported_mtmsn = mtmsn + else: + reported_mtmsn = -1 + + + return_string = "\nSBDS:%d,%d,%d,%d\r\n" % (mo_flag, momsn, mt_flag, mtmsn) + + ser.write(return_string) + send_ok() + +def copy_mo_to_mt(): + global mo_buffer + global mt_buffer + + mt_buffer = mo_buffer + + return_string = "\nSBDTC: Outbound SBD Copied to Inbound SBD: size = %d\r\n" % (len(mo_buffer)) + ser.write(return_string) + + send_ok() + +def which_gateway(): + return_string = "\rSBDGW:EMSS\r\n" + + ser.write(return_string) + send_ok() + +def get_system_time(): + return_string = "\r\n---MSSTM: 01002000\r\n" + ser.write(return_string) + send_ok() + print 'We havent actually implemented MSSTM this yet.' + +def set_ring_indicator(cmd,start_index): + global ring_enable + + text = cmd[start_index:len(cmd)-1] + + if len(text) == 1: + if text[0] == '0': + send_ok() + elif text[0] == '1': + send_ok() + else: + send_error() + else: + send_error() + + +def get_signal_strength(): + return_string = "\r\n+CSQ:%d\r\n" % 5#(random.randint(0,5)) + time.sleep(AVERAGE_SBDIX_DELAY) + ser.write(return_string) + send_ok() + +def get_valid_rssi(): + return_string = "\n+CSQ:(0-5)\r\n" + ser.write(return_string) + send_ok() + +def get_lock_status(): + global locked + + return_string = "\n+CULK:%d\r\n" % ( locked ) + ser.write(return_string) + send_ok() + +def get_manufacturer(): + return_string = "\n+Iridium\r\n" + ser.write(return_string) + send_ok() + +def get_model(): + return_string = "\nIRIDIUM 9600 Family SBD Transceiver\r\n" + ser.write(return_string) + send_ok() + +def get_gsn(): + return_string = "\n300234060604220\r\n" + ser.write(return_string) + send_ok() + +def get_gmr(): + return_string = "\n3Call Processor Version: Long string\r\n" + print 'Warning: get_gmr function not fully implemented' + ser.write(return_string) + send_ok() + +def write_binary_start(cmd,start_index): + global binary_rx_incoming_bytes + global binary_rx + + text = cmd[start_index:len(cmd)-1] + try: + binary_rx_incoming_bytes = int(text) + if (binary_rx_incoming_bytes > 340): + ser.write('\r\r\n3\r\n') + send_ok() + binary_rx_incoming_bytes = 0 + else: + print 'Ready to receive {} bytes'.format(binary_rx_incoming_bytes) + send_ready() + binary_rx = True + except: + send_error() + +def parse_cmd(cmd): + global echo + #get string up to newline or '=' + index = cmd.find('=') + if index == -1: + index = cmd.find('\r') + cmd_type = cmd[0:index].lower() + + #print cmd_type + + if cmd_type == 'at' : do_ok() + elif cmd_type == 'at+csq' : get_signal_strength() + elif cmd_type == 'at+csq=?' : get_valid_rssi() + elif cmd_type == 'at+culk?' : get_lock_status() + elif cmd_type == 'at+gmi' : get_manufacturer() + elif cmd_type == 'at+gmm' : get_model() + elif cmd_type == 'at+gsn' : get_gsn() + elif cmd_type == 'at+gmr' : get_gmr() + elif cmd_type == 'at+sbdwt' : write_text(cmd,index + 1) + elif cmd_type == 'at+sbdwb' : write_binary_start(cmd,index + 1) + elif cmd_type == 'at+sbdi' : sbdi() + elif cmd_type == 'at+sbdix' : sbdix() + elif cmd_type == 'at+sbdreg' : sbd_reg() + elif cmd_type == 'at+sbdreg?' : check_reg_status() + elif cmd_type == 'at+sbddet' : sbd_det() + elif cmd_type == 'at+sbdrt' : read_text() + elif cmd_type == 'at+sbdrb' : read_binary() + elif cmd_type == 'at+sbdd0' : clear_buffers(0) + elif cmd_type == 'at+sbdd1' : clear_buffers(1) + elif cmd_type == 'at+sbdd2' : clear_buffers(2) + elif cmd_type == 'at+sbdc' : clear_momsn() + elif cmd_type == 'at+sbds' : get_sbd_status() + elif cmd_type == 'at+sbdtc' : copy_mo_to_mt() + elif cmd_type == 'at+sbdgw' : which_gateway() + elif cmd_type == 'at-msstm' : get_system_time() + elif cmd_type == 'at+sbdmta' : set_ring_indicator(cmd,index + 1) + elif cmd_type == 'ate0' or cmd_type == 'ate': + echo = False + do_ok() + elif cmd_type == 'ate1' : do_ok() + elif cmd_type == 'at&d0' : do_ok() + elif cmd_type == 'at&k0' : do_ok() + else : send_error() + + +def open_port(dev,baudrate): + ser = serial.Serial(dev, 19200, timeout=.1, parity=serial.PARITY_NONE) + return ser + +class MobileTerminatedHandler(asyncore.dispatcher_with_send): + def __init__(self, sock, addr): + asyncore.dispatcher_with_send.__init__(self, sock) + self.client = None + self.addr = addr + self.data = "" + self.msg_length = 0 + self.preheader_fmt = '!bH' + self.preheader_size = struct.calcsize(self.preheader_fmt) + + def handle_read(self): + global mt_messages + + if len(self.data) < self.preheader_size: + self.data += self.recv(self.preheader_size) + preheader = struct.unpack(self.preheader_fmt, self.data) + self.msg_length = preheader[1] + else: + self.data += self.recv(self.msg_length) + + print self.msg_length + print self.data.encode("hex") + + if len(self.data) >= self.msg_length: + mt_packet = None + try: + mt_packet = parse_mt_directip_packet(self.data, mt_messages) + except: + print 'MT Handler: Invalid message' + # response message + self.send(assemble_mt_directip_response(mt_packet, mt_messages)) + self.handle_close() + + def handle_close(self): + print 'MT Handler: Connection closed from %s' % repr(self.addr) + sys.stdout.flush() + self.close() + + +class MobileTerminatedServer(asyncore.dispatcher): + + def __init__(self, host, port): + asyncore.dispatcher.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.bind((host, port)) + self.listen(5) + + def handle_accept(self): + pair = self.accept() + if pair is not None: + sock, addr = pair + print 'MT Handler: Incoming connection from %s' % repr(addr) + sys.stdout.flush() + try: + handler = MobileTerminatedHandler(sock, addr) + except: + print "MT Handler: Unexpected error:", sys.exc_info()[0] + + + + +def main(): + + global ser + global mo_buffer + global mo_set + global binary_rx_incoming_bytes + global binary_rx + + global user + global recipient + global incoming_server + global outgoing_server + global password + + global email_enabled + global ip_enabled + global http_post_enabled + + global mo_ip + global mo_port + global mt_port + + global echo + + global http_queue + global webhook_server_endpoint + + + parser = OptionParser() + parser.add_option("-d", "--dev", dest="dev", action="store", help="tty dev(ex. '/dev/ttyUSB0'", metavar="DEV") + parser.add_option("--passwd", dest="passwd", action="store", help="Password", metavar="PASSWD") + parser.add_option("-u", "--user", dest="user", action="store", help="E-mail account username", metavar="USER") + parser.add_option("-r", "--recipient", dest="recipient", action="store", help="Destination e-mail address.", metavar="USER") + parser.add_option("-i", "--in_srv", dest="in_srv", action="store", help="Incoming e-mail server url", metavar="IN_SRV") + parser.add_option("-o", "--out_srv", dest="out_srv", action="store", help="Outging e-mail server", metavar="OUT_SRV") + parser.add_option("--mo_ip", dest="mo_ip", action="store", help="Mobile-originated DirectIP server IP address", metavar="MO_IP", default="127.0.0.1") + parser.add_option("--mo_port", dest="mo_port", action="store", help="Mobile-originated DirectIP server Port", metavar="MO_PORT", default=10801) + parser.add_option("--mt_port", dest="mt_port", action="store", help="Mobile-terminated DirectIP server Port", metavar="MT_PORT", default=10800) + parser.add_option("-m", "--mode", dest="mode", action="store", help="Mode: EMAIL,HTTP_POST,IP,NONE", default="NONE", metavar="MODE") + parser.add_option("--imei", dest="imei", action="store", help="IMEI for this modem", default="300234060379270", metavar="MODE") + + # HTTP related args + parser.add_option("--webhook_server_endpoint", dest="webhook_server_endpoint", action="store", help="Where to post webhooks", default=None) + parser.add_option("--http_server_port", dest="http_server_port", action="store", help="Port number for the http server", default=None) + + (options, args) = parser.parse_args() + + mt_port = int(options.mt_port) + + #check for valid arguments + if options.mode == "EMAIL": + if options.passwd is None or options.user is None or options.recipient is None or options.in_srv is None or options.out_srv is None: + print 'If you want to use e-mail, you must specify in/out servers, user, password, and recipient address.' + sys.exit() + else: + email_enabled = True + elif options.mode == "HTTP_POST": + http_post_enabled = True + if options.webhook_server_endpoint is None or options.http_server_port is None: + print "You have missing arguments." + print "Usage: python2 Iridium9602.py --webhook_server_endpoint \ + --http_server_port -d -m HTTP_POST" + sys.exit() + elif options.mode == "IP": + print 'Using IP mode with MO ({}:{}) and MT (0.0.0.0:{}) servers'.format(options.mo_ip, int(options.mo_port), options.mt_port) + server = MobileTerminatedServer('0.0.0.0', mt_port) + print "Started MT Server on port {}".format(mt_port) + sys.stdout.flush() + ip_enabled = True + else: + print "No valid mode specified" + sys.exit() + + + user = options.user + recipient = options.recipient + incoming_server = options.in_srv + outgoing_server = options.out_srv + password = options.passwd + + mo_ip = options.mo_ip + mo_port = int(options.mo_port) + imei = options.imei + + now_get_checksum_first = False + now_get_checksum_second = False + + try: + ser = open_port(options.dev,19200) + except: + print "Could not open serial port. Exiting." +# print "FYI - Here's a list of ports on your system." +# print list_serial_ports() + sys.exit() + + if http_post_enabled: + # Runs server + try: + webhook_server_endpoint = options.webhook_server_endpoint + server_port = int(options.http_server_port) + server = runServer(server_port) + server.start() + except: + print "Could not start server. Exiting." + sys.exit() + + rx_buffer = '' + + binary_checksum = 0 + + while(1): + signal.signal(signal.SIGINT, signal_handler) + + if ip_enabled: + asyncore.loop(timeout=0, count=1) # non-blocking loop + + new_char = ser.read() # timeout after .1 seconds to return to asyncore.loop() + if (len(new_char) == 0): + continue + + print(new_char) + + if echo and not binary_rx: + ser.write(new_char) + + if not binary_rx: + rx_buffer = rx_buffer + new_char + #look for eol char, #TODO figure out what is really devined as EOL for iridium modem + if new_char == chr(EOL_CHAR): + if(len(rx_buffer) > 2): + print "Here is what I received:%s" % (rx_buffer) + parse_cmd(rx_buffer) + rx_buffer = '' + else: + rx_buffer = '' + + #process backspace + elif new_char == chr(BACKSPACE_CHAR) and not binary_rx: + rx_buffer[:len(rx_buffer)-1] + rx_buffer = rx_buffer[:len(rx_buffer)-2] #remove char if backspace + else: + if now_get_checksum_first: + checksum_first = ord(new_char) + now_get_checksum_first = False + now_get_checksum_second = True + elif now_get_checksum_second: + checksum_second = ord(new_char) + now_get_checksum_first = False + now_get_checksum_second = False + #check the checksum + if (checksum_first * 256 + checksum_second) == (binary_checksum & (2**16-1)): + print "Good binary checksum" + ser.write('\r\n0\r\n') + send_ok() + mo_buffer = rx_buffer + rx_buffer = '' + mo_set = True + else: + print "Bad binary checksum" + ser.write('\r\n2\r\n') + send_ok() + rx_buffer = '' + ser.write('\n') + binary_checksum = 0 + binary_rx = False + else: + if binary_rx_incoming_bytes == 1: + now_get_checksum_first = True + binary_checksum = binary_checksum + ord(new_char) + rx_buffer = rx_buffer + new_char + else: + binary_rx_incoming_bytes -= 1 + rx_buffer = rx_buffer + new_char + binary_checksum = binary_checksum + ord(new_char) + + + +if __name__ == '__main__': + main() diff --git a/src/virtual_iridium/python/__init__.py b/src/virtual_iridium/python/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/virtual_iridium/python/imap_stuff.py b/src/virtual_iridium/python/imap_stuff.py new file mode 100644 index 000000000..caa5a83b3 --- /dev/null +++ b/src/virtual_iridium/python/imap_stuff.py @@ -0,0 +1,48 @@ + +#v+ +#!/usr/bin/env python + +import imaplib +import email +import imaplib + +def checkMessages(incoming_server, user, password,imsi): + has_data = False + obj = imaplib.IMAP4_SSL(incoming_server, '993') + obj.login(user, password) + obj.select('Inbox') + typ ,data = obj.search(None,'UnSeen') + string = data[0] + msg_count = len(string.split(' ')) + index = string.find(' ') + if not index == -1: + string = string[:string.find(' ')] + if(msg_count >= 1 and len(string) > 0): + obj.store(string,'+FLAGS','\Seen') + typ, data = obj.fetch(string, '(RFC822)') + + #TODO: filter by imei subject + + #get attachment + text = data[0][1] + msg = email.message_from_string(text) + for part in msg.walk(): + if part.get_content_maintype() == 'multipart': + continue + if part.get('Content-Disposition') is None: + continue + filename = part.get_filename() + data = part.get_payload(decode=True) + if not data: + has_data = False + data = [] + continue + has_data = True + + return data, has_data, max(msg_count - 1,0) + +#def main(): +# checkMessages('imap.gmail.com','jmalsbury.personal@gmail.com','sweet525',0) + +#if __name__ == '__main__': +# main() diff --git a/src/virtual_iridium/python/imap_stuff.pyc b/src/virtual_iridium/python/imap_stuff.pyc new file mode 100644 index 000000000..b73294f84 Binary files /dev/null and b/src/virtual_iridium/python/imap_stuff.pyc differ diff --git a/src/virtual_iridium/python/iridium_mo_forward_server.py b/src/virtual_iridium/python/iridium_mo_forward_server.py new file mode 100755 index 000000000..fc3b00878 --- /dev/null +++ b/src/virtual_iridium/python/iridium_mo_forward_server.py @@ -0,0 +1,130 @@ +#!/usr/bin/python + +# splits traffic to Hayes Modem emulator and to an Iridium9602 simulator + +import asyncore +import socket +from optparse import OptionParser + + +class ConditionalForwardClient(asyncore.dispatcher_with_send): + + def __init__(self, server, host, port): + asyncore.dispatcher_with_send.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect( (host, port) ) + self.server = server + + def handle_read(self): + data = self.recv(64) + if data: + self.server.send(data) + + +class ConditionalForwardHandler(asyncore.dispatcher_with_send): + + def __init__(self, sock, addr): + asyncore.dispatcher_with_send.__init__(self, sock) + self.identified_protocol = False + self.addr = addr + self.initial_data = "" + self.buf = "" + self.hayes_client = ConditionalForwardClient(self, options.hayes_server, int(options.hayes_port)) + self.sbd_client = ConditionalForwardClient(self, options.sbd_server, int(options.sbd_port)) + self.sbd_write = False + self.sbd_bytes_remaining = 0 + + def handle_read(self): + data = self.recv(256) + + print data.encode("hex") + + if not data: + return + elif self.sbd_write: # not line mode - raw data + self.sbd_send_bytes(data) + elif data == "+++": + self.hayes_client.send(data) + else: # line based Command data + self.buf += data + line_list = self.buf.split('\r') + # partial line + self.buf = line_list[-1] + + for line in line_list[0:-1]: + self.line_process(line) + + def handle_close(self): + print 'Connection closed from %s' % repr(self.addr) + sys.stdout.flush() + self.close() + + def line_process(self, line): + line_cr = line + '\r' + + if line.strip().upper() in ['ATE']: + self.hayes_client.send(line_cr) + self.sbd_client.send(line_cr) + else: + if len(line) >= 6 and line[2:6].upper() == "+SBD": + self.sbd_client.send(line_cr) + if len(line) >= 8 and line[2:8].upper() == "+SBDWB": + parts = line.split('=') + self.sbd_bytes_remaining = int(parts[1]) + 2 # 2 checksum bytes + self.sbd_write = True + else: + self.hayes_client.send(line_cr) + + def sbd_send_bytes(self, bytes): + self.sbd_bytes_remaining -= len(bytes) + self.sbd_client.send(bytes) + print self.sbd_bytes_remaining + if self.sbd_bytes_remaining <= 0: + self.sbd_write = False + +class ConditionalForwardServer(asyncore.dispatcher): + + def __init__(self, host, port): + asyncore.dispatcher.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.bind((host, port)) + self.listen(5) + + def handle_accept(self): + pair = self.accept() + if pair is not None: + sock, addr = pair + print 'Incoming connection from %s' % repr(addr) + sys.stdout.flush() + try: + handler = ConditionalForwardHandler(sock, addr) + except: + print "Unexpected error:", sys.exc_info()[0] + +import sys + + + + + +parser = OptionParser() +parser.add_option("-p", "--port", dest="port", action="store", help="bind port", default=4010) +parser.add_option("-a", "--hayes_address", dest="hayes_server", action="store", help="address to connect to Hayes AT emulator", default="127.0.0.1") +parser.add_option("-b", "--hayes_port", dest="hayes_port", action="store", help="address to connect to Hayes AT emulator", default=4001) +parser.add_option("-c", "--sbd_address", dest="sbd_server", action="store", help="address to connect to SBD emulator", default="127.0.0.1") +parser.add_option("-d", "--sbd_port", dest="sbd_port", action="store", help="address to connect to SBD emulator", default=4020) + +(options, args) = parser.parse_args() + + +forward_address = '0.0.0.0' + +print "Iridium Port forwarder starting up ..." +print "Listening on port: {}".format(int(options.port)) +print "Connecting for Iridium9602 SBD on %s:%d" % (options.sbd_server, int(options.sbd_port)) +print "Connecting for Hayes (ATDuck) on %s:%d" % (options.hayes_server, int(options.hayes_port)) +sys.stdout.flush() + +server = ConditionalForwardServer(forward_address, int(options.port)) +asyncore.loop() diff --git a/src/virtual_iridium/python/iridium_mt_forward_server.py b/src/virtual_iridium/python/iridium_mt_forward_server.py new file mode 100755 index 000000000..84462770e --- /dev/null +++ b/src/virtual_iridium/python/iridium_mt_forward_server.py @@ -0,0 +1,115 @@ +#!/usr/bin/python + +# Handles incoming Iridium SBD traffic and port forwards as appropriate based on IMEI +# Used to allow you to run multiple Iridium9602 simulator instances that can be handled +# by a single MT DirectIP server + +import asyncore +import socket +from virtual_iridium.sbd_packets import parse_mt_directip_packet +from collections import deque +import struct + +# this script listens (binds) on this port +mt_sbd_address = '0.0.0.0' +mt_sbd_port = 40002 + +# maps imei to address and port +forward_address = { "300234060379270" : ("127.0.0.1",40010), "300234060379271" : ("127.0.0.1",40011), "300234060379272" : ("127.0.0.1",40012), "300234060379273" : ("127.0.0.1",40013), "300234060379274" : ("127.0.0.1",40014) } + +class ConditionalSBDForwardClient(asyncore.dispatcher_with_send): + + def __init__(self, server, host, port): + asyncore.dispatcher_with_send.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect( (host, port) ) + self.server = server + + def handle_read(self): + data = self.recv(64) + if data: + self.server.send(data) + + +class ConditionalSBDForwardHandler(asyncore.dispatcher_with_send): + + def __init__(self, sock, addr): + asyncore.dispatcher_with_send.__init__(self, sock) + self.identified_protocol = False + self.client = None + self.addr = addr + self.data = '' + self.preheader_fmt = '!bH' + self.preheader_size = struct.calcsize(self.preheader_fmt) + + def handle_read(self): + if len(self.data) < self.preheader_size: + self.data += self.recv(self.preheader_size) + if not self.data: + return + preheader = struct.unpack(self.preheader_fmt, self.data) + self.msg_length = preheader[1] + else: + self.data += self.recv(self.msg_length) + + print self.msg_length + print self.data.encode("hex") + + if len(self.data) >= self.msg_length: + mt_packet = None + mt_messages = deque() + try: + mt_packet = parse_mt_directip_packet(self.data, mt_messages) + except: + print 'MT Handler: Invalid message' + sys.stdout.flush() + + imei = mt_packet[0][1] + + print 'Attempting to forward message for imei: {}' .format(imei) + + if forward_address.has_key(imei): + self.client = ConditionalSBDForwardClient(self, forward_address[imei][0], forward_address[imei][1]) + self.client.send(self.data) + self.data = '' + else: + print 'No forwarding set up for imei: {}'.format(imei) + self.close() + + def handle_close(self): + print 'Connection closed from %s' % repr(self.addr) + sys.stdout.flush() + if self.client is not None: + self.client.close() + self.close() + + +class ConditionalSBDForwardServer(asyncore.dispatcher): + + def __init__(self, host, port): + asyncore.dispatcher.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.bind((host, port)) + self.listen(5) + + def handle_accept(self): + pair = self.accept() + if pair is not None: + sock, addr = pair + print 'Incoming connection from %s' % repr(addr) + sys.stdout.flush() + try: + handler = ConditionalSBDForwardHandler(sock, addr) + except: + print "Unexpected error:", sys.exc_info()[0] + +import sys +print "Iridium SBD Port forwarder starting up ..." +print "Listening for SBD on port: %d" % mt_sbd_port + + +sys.stdout.flush() + +sbd_server = ConditionalSBDForwardServer(mt_sbd_address, mt_sbd_port) +asyncore.loop() diff --git a/src/virtual_iridium/python/iridium_rudics_shore_connection.py b/src/virtual_iridium/python/iridium_rudics_shore_connection.py new file mode 100755 index 000000000..d137a8afd --- /dev/null +++ b/src/virtual_iridium/python/iridium_rudics_shore_connection.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +from optparse import OptionParser +import socket +import time + +def close_hayes_socket(hayes_socket): + hayes_socket.send("+++"); + time.sleep(2) + hayes_socket.send("ATH\r\n"); + + +def main(): + parser = OptionParser() + parser.add_option("-a", "--hayes_address", dest="hayes_address", action="store", help="Hayes Simulator Address") + parser.add_option("-p", "--hayes_port", dest="hayes_port", action="store", help="Hayes Simulator Port") + + parser.add_option("-A", "--shore_address", dest="shore_address", action="store", help="Shore driver Address") + parser.add_option("-P", "--shore_port", dest="shore_port", action="store", help="Shore driver Port") + + (options, args) = parser.parse_args() + print options + + connected = False + buffer_size = 1024 + + shore_socket = None; + + + hayes_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + hayes_socket.connect((options.hayes_address, int(options.hayes_port))) + hayes_socket.settimeout(0.1) + hayes_socket.send("OK") + while(1): + try: + if connected: + try: + shore_data = shore_socket.recv(buffer_size) + if(len(shore_data) > 0): + hayes_socket.send(shore_data) + else: + print "Zero read" + connected = False + close_hayes_socket(hayes_socket) + except socket.timeout: + pass + except socket.error as e: + print "Shore socket error: ",e + connected = False + close_hayes_socket(hayes_socket, connected) + + data = hayes_socket.recv(buffer_size) + print data + + if("NO CARRIER" in data): + print "Disconnected!" + connected = False + shore_socket.close() + + if connected and len(data) > 0: + try: + print "To Shore: ",data.encode("hex") + shore_socket.send(data) + except socket.timeout: + print "Timeout sending data" + except socket.error as e: + print "Shore socket error: ",e + connected = False + hayes_socket.send("+++"); + time.sleep(2) + hayes_socket.send("ATH\r\n"); + + if(data.strip() == "RING"): + hayes_socket.send("ATA\r\n"); + elif("CONNECT" in data): + print "Connected!" + shore_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + shore_socket.settimeout(0.1) + shore_socket.connect((options.shore_address, int(options.shore_port))) + connected = True + + except socket.timeout: + time.sleep(0.01) + except socket.error as e: + print e + + +if __name__ == '__main__': + main() diff --git a/src/virtual_iridium/python/sbd_packets.py b/src/virtual_iridium/python/sbd_packets.py new file mode 100644 index 000000000..3961b27be --- /dev/null +++ b/src/virtual_iridium/python/sbd_packets.py @@ -0,0 +1,110 @@ +import struct +import random +import time + +def assemble_mo_directip_packet(imei, momsn, mtmsn, mo_buffer): + + # ==== MO HEADER ==== + # MO Header IEI char 0x01 + # MO Header Length unsigned short + # CDR Reference (Auto ID) unsigned integer + # IMEI char[] (15 bytes) + # Session Status unsigned char + # MOMSN unsigned short + # MTMSN unsigned short + # Time of Session unsigned integer + header_fmt = "!bHI15sBHHI" + header_iei = 0x01 + # header_length does not include iei and length fields + header_length = struct.calcsize(header_fmt) - struct.calcsize('!bH') + cdr_ref = random.getrandbits(32) + session_status = 0 + header = struct.pack(header_fmt, header_iei, header_length, cdr_ref, str(imei), session_status, momsn, mtmsn, int(time.time())) + + # ==== MO PAYLOAD ==== + # MO Payload IEI char 0x02 + # MO Payload Length unsigned short + # MO Payload char + payload_iei = 0x02 + payload_length = min(len(mo_buffer), 1960) + payload = struct.pack('!bH' + str(payload_length) + 's', payload_iei, payload_length, mo_buffer) + + protocol_rev_no = 1 + overall_msg_length = len(header) + len(payload) + preheader = struct.pack('!bH', protocol_rev_no, overall_msg_length) + return preheader + header + payload + +def parse_mt_directip_packet(buffer, mt_messages): + + parse_offset = 0 + preheader_fmt = '!bH' + ie_header_fmt = '!bH' + preheader = struct.unpack_from(preheader_fmt, buffer, parse_offset) + parse_offset += struct.calcsize(preheader_fmt) + + header_iei = 0x41 + payload_iei = 0x42 + prio_iei = 0x46 + header = None + payload = None + + while parse_offset + struct.calcsize(ie_header_fmt) < len(buffer): + ie_header = struct.unpack_from(ie_header_fmt, buffer, parse_offset) + print 'IE Header: ' + str(ie_header) + parse_offset += struct.calcsize(ie_header_fmt) + + if ie_header[0] == header_iei: + # ==== MT HEADER ==== + # MT Header IEI char 0x41 + # MT Header Length unsigned short + # Unique Client Message ID unsigned int + # IMEI (User ID) char[] (15 bytes) + # MT Disposition Flags char unsigned short + header_fmt = '!I15sH' + header = struct.unpack_from(header_fmt, buffer, parse_offset) + print 'Header: ' + str(header) + elif ie_header[0] == payload_iei: + # ==== MT PAYLOAD ==== + # MO Payload IEI char 2 + # MO Payload Length unsigned short + # MO Payload char + payload_fmt = str(ie_header[1]) + 's' + payload = struct.unpack_from(payload_fmt, buffer, parse_offset) + print 'Payload: ' + str(payload) + mt_messages.append(payload[0]) + else: + print 'Unknown IEI: %x'.format(ie_header[0]) + + parse_offset += ie_header[1] + return (header, payload) + +def assemble_mt_directip_response(mt_packet, mt_messages): + + confirm_iei = 0x44 + confirm_fmt = "!bHI15sIh" + confirm_length = struct.calcsize(confirm_fmt) - struct.calcsize('!bH') + session_status = 0 + client_id = 0 + imei = '0'*15 + if mt_packet[0] is not None: + print mt_packet + session_status = len(mt_messages) + client_id = mt_packet[0][0] + imei = mt_packet[0][1] + else: + session_status = -7 # violation of MT DirectIP protocol error + + + # MT Confirmation Message IEI + # MT Confirmation Message Length + # Unique Client Message ID + # IMEI (User ID) + # Auto ID Reference + # MT Message Status + auto_id = random.getrandbits(32) + confirm = struct.pack(confirm_fmt, confirm_iei, confirm_length, client_id, imei, auto_id, session_status) + + protocol_rev_no = 1 + overall_msg_length = len(confirm) + preheader = struct.pack('!bH', protocol_rev_no, overall_msg_length) + return preheader + confirm diff --git a/src/virtual_iridium/python/sbd_packets.pyc b/src/virtual_iridium/python/sbd_packets.pyc new file mode 100644 index 000000000..8e7a07ce0 Binary files /dev/null and b/src/virtual_iridium/python/sbd_packets.pyc differ diff --git a/src/virtual_iridium/python/smtp_stuff.py b/src/virtual_iridium/python/smtp_stuff.py new file mode 100644 index 000000000..b82aa06d9 --- /dev/null +++ b/src/virtual_iridium/python/smtp_stuff.py @@ -0,0 +1,61 @@ +import os +import smtplib +import mimetypes +from email.MIMEMultipart import MIMEMultipart +from email.MIMEBase import MIMEBase +from email.MIMEText import MIMEText +from email.MIMEAudio import MIMEAudio +from email.MIMEImage import MIMEImage +from email.Encoders import encode_base64 + +def sendMail(subject, text, user, recipient, password, smtp_server, attachmentFilePath): + gmailUser = user + gmailPassword = password + + msg = MIMEMultipart() + msg['From'] = 'sbdservice@sbd.iridium.com' + msg['To'] = recipient + msg['Subject'] = subject + msg.attach(MIMEText(text)) + + + msg.attach(getAttachment(attachmentFilePath)) + + mailServer = smtplib.SMTP(smtp_server, 587) + mailServer.ehlo() + mailServer.starttls() + mailServer.ehlo() + mailServer.login(gmailUser, gmailPassword) + mailServer.sendmail(gmailUser, recipient, msg.as_string()) + mailServer.close() + + print('Sent email to %s' % recipient) + +def getAttachment(attachmentFilePath): + contentType, encoding = mimetypes.guess_type(attachmentFilePath) + + if contentType is None or encoding is not None: + contentType = 'application/octet-stream' + + mainType, subType = contentType.split('/', 1) + file = open(attachmentFilePath, 'rb') + + if mainType == 'text': + attachment = MIMEText(file.read()) + elif mainType == 'message': + attachment = email.message_from_file(file) + elif mainType == 'image': + attachment = MIMEImage(file.read(),_subType=subType) + elif mainType == 'audio': + attachment = MIMEAudio(file.read(),_subType=subType) + else: + attachment = MIMEBase(mainType, subType) + attachment.set_payload(file.read()) + encode_base64(attachment) + + file.close() + + attachment.add_header('Content-Disposition', 'attachment', filename=os.path.basename(attachmentFilePath)) + return attachment + + diff --git a/src/virtual_iridium/python/smtp_stuff.pyc b/src/virtual_iridium/python/smtp_stuff.pyc new file mode 100644 index 000000000..a1a49b70c Binary files /dev/null and b/src/virtual_iridium/python/smtp_stuff.pyc differ diff --git a/src/virtual_iridium/setup.py b/src/virtual_iridium/setup.py new file mode 100644 index 000000000..44dddb477 --- /dev/null +++ b/src/virtual_iridium/setup.py @@ -0,0 +1,7 @@ +from distutils.core import setup +setup(name='virtual_iridium', + version='1.0', + package_dir={'virtual_iridium': 'python'}, + packages=['virtual_iridium'], + scripts=['python/Iridium9602.py', 'python/iridium_mo_forward_server.py', 'python/iridium_mt_forward_server.py', 'python/iridium_rudics_shore_connection.py'] + ) diff --git a/src/website/.env.local b/src/website/.env.local new file mode 100644 index 000000000..d7ba8f037 --- /dev/null +++ b/src/website/.env.local @@ -0,0 +1,4 @@ +MONGODB_URI=mongodb://localhost:27017/sailbot_db +NEXT_PUBLIC_SERVER_HOST=http://localhost +NEXT_PUBLIC_SERVER_PORT=3005 +NEXT_PUBLIC_POLLING_TIME_MS=60000 diff --git a/src/website/.eslintrc.js b/src/website/.eslintrc.js new file mode 100644 index 000000000..3fa730abb --- /dev/null +++ b/src/website/.eslintrc.js @@ -0,0 +1,26 @@ +/* eslint-env node */ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: ['eslint:recommended', 'next/core-web-vitals', 'prettier'], + overrides: [ + { + files: ['*.ts', '*.tsx'], + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, + }, + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + + //declaring 'next/core-web-vitals' and 'prettier' again in case + //the two plugin:... configs above overrode any of their rules + //Also, 'prettier' needs to be last in any extends array + 'next/core-web-vitals', + 'prettier', + ], + }, + ], +}; diff --git a/src/website/.gitignore b/src/website/.gitignore new file mode 100644 index 000000000..4efce6dbe --- /dev/null +++ b/src/website/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# lockfile +# package-lock.json diff --git a/src/website/.prettierignore b/src/website/.prettierignore new file mode 100644 index 000000000..a47e5da3a --- /dev/null +++ b/src/website/.prettierignore @@ -0,0 +1,7 @@ +node_modules +.next +.husky +coverage +.prettierignore +.stylelintignore +.eslintignore diff --git a/src/website/.prettierrc.json b/src/website/.prettierrc.json new file mode 100644 index 000000000..e29d50166 --- /dev/null +++ b/src/website/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "jsxSingleQuote": true +} diff --git a/src/website/.stylelintrc.json b/src/website/.stylelintrc.json new file mode 100644 index 000000000..9458694a7 --- /dev/null +++ b/src/website/.stylelintrc.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "stylelint-config-standard-scss", + "stylelint-config-prettier-scss" + ], + "rules": { + "selector-class-pattern": null + } +} diff --git a/src/website/README.md b/src/website/README.md new file mode 100644 index 000000000..c8ed90869 --- /dev/null +++ b/src/website/README.md @@ -0,0 +1,79 @@ +# Website + +In the website development timeline, we are currently evaluating the folllowing software stack: +[Next.js](https://nextjs.org/) website (this repository) and the [MongoDB](https://www.mongodb.com/) database. +The easiest way to evaluate these potential solutions for our purposes is in [sailbot_workspace](https://github.com/UBCSailbot/sailbot_workspace). + +## Database + +[MongoDB](https://www.mongodb.com/) is a general purpose, document-based, distributed database built for modern application +developers and for the cloud era. If you want to learn more about MongoDB, visit their docs site: [MongoDB Documentation](https://docs.mongodb.com/). + +## Setup + +### Environment variables + +This project uses environment variables to manage configuration-specific information. Please look at the file +`.env.local` and ensure the variables are defined below: + +- `MONGODB_URI`: Your MongoDB connection string. Use `mongodb://localhost:27017/` to establish a connection + with the local database. +- `NEXT_PUBLIC_SERVER_HOST`: The host URL of the website. +- `NEXT_PUBLIC_SERVER_PORT`: The port number of the website. +- `NEXT_PUBLIC_POLLING_TIME_MS`: The time interval for polling the database in milliseconds. + +### Package installation + +The following command installs all required dependencies listed in the `package.json` file: + +``` +npm install +``` + +Once the installation is complete, you should see a `node_modules` directory in the project's root. +This directory contains all installed packages. + +When installing a new package to the website, please follow the steps below: + +1. Access the terminal of the website container on Docker. + +2. Run the command `npm install `. + Replace `` with the actual name of the package you want to add. + + - Should you encounter errors related to resolving peer dependencies, please re-run the command with + the header `--legacy-peer-deps`. Do not to use `--force` unless you're well aware of the potential consequences. + +3. Review the `package.json` file to ensure the new package and its version have been added to the dependencies section. + - Confirm that `package-lock.json` has also been updated. + This file holds specific version information to ensure consistent installations across different environments. +4. Once the installation process is finished, please make sure to commit the files `package.json` and `package-lock.json`. + These files are essential for version controlling the dependencies that have been added. + +## Run + +Using [Sailbot Workspace](https://github.com/UBCSailbot/sailbot_workspace), +the website should be up and running on [http://localhost:3005](http://localhost:3005). + +Otherwise, you execute the following commands to run it in development mode: + +```bash +npm run dev +``` + +## Linters + +Before merging in new changes to the repository, please execute the following commands in order: + +```bash +npm run format +``` + +This command runs [Prettier](https://prettier.io/docs/en/index.html) to automatically format the code according to +the rules defined in the configuration file `.prettierrc`. + +```bash +npm run lint +``` + +This command runs [ESLint](https://eslint.org/docs/latest/use/getting-started) to analyze the code for potential errors +and enforce coding style based on the rules defined in the configuration file `.eslintrc`. diff --git a/src/website/lib/dotenv.ts b/src/website/lib/dotenv.ts new file mode 100644 index 000000000..934f0ec21 --- /dev/null +++ b/src/website/lib/dotenv.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +const webpack = require('webpack'); +const dotenv = require('dotenv'); + +module.exports = () => { + // call dotenv and it will return an Object with a parsed key + const env = dotenv.config().parsed; + + // reduce it to a nice object, the same as before + const envKeys = Object.keys(env).reduce((prev, next) => { + prev[`process.env.${next}`] = JSON.stringify(env[next]); + return prev; + }, {}); + + return { + plugins: [new webpack.DefinePlugin(envKeys)], + }; +}; diff --git a/src/website/lib/mongodb.ts b/src/website/lib/mongodb.ts new file mode 100644 index 000000000..e80f7f3c3 --- /dev/null +++ b/src/website/lib/mongodb.ts @@ -0,0 +1,50 @@ +/* eslint-disable */ +import mongoose from 'mongoose'; + +const dotenv = require('dotenv'); +dotenv.config({ path: '/website/.env.local' }); + +declare global { + var mongoose: any; // This must be a `var` and not a `let / const` +} + +const MONGODB_URI = process.env.MONGODB_URI!; + +if (!MONGODB_URI) { + throw new Error( + 'Please define the MONGODB_URI environment variable inside .env.local', + ); +} + +let cached = global.mongoose; + +if (!cached) { + cached = global.mongoose = { conn: null, promise: null }; +} + +/* Use the statement below when we need to refresh Models */ +// delete mongoose.connection.models['']; // + +async function ConnectMongoDB() { + if (cached.conn) { + return cached.conn; + } + if (!cached.promise) { + const opts = { + bufferCommands: false, + }; + cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => { + return mongoose; + }); + } + try { + cached.conn = await cached.promise; + } catch (e) { + cached.promise = null; + throw e; + } + + return cached.conn; +} + +export default ConnectMongoDB; diff --git a/src/website/lib/providers.tsx b/src/website/lib/providers.tsx new file mode 100644 index 000000000..b197248d1 --- /dev/null +++ b/src/website/lib/providers.tsx @@ -0,0 +1,11 @@ +'use client'; + +/* Core */ +import { Provider } from 'react-redux'; + +/* Instruments */ +import { reduxStore } from '@/lib/redux'; + +export const Providers = (props: React.PropsWithChildren) => { + return {props.children}; +}; diff --git a/src/website/lib/redux/index.ts b/src/website/lib/redux/index.ts new file mode 100644 index 000000000..d4068169b --- /dev/null +++ b/src/website/lib/redux/index.ts @@ -0,0 +1 @@ +export * from './store'; diff --git a/src/website/lib/redux/middleware.ts b/src/website/lib/redux/middleware.ts new file mode 100644 index 000000000..b106a69b2 --- /dev/null +++ b/src/website/lib/redux/middleware.ts @@ -0,0 +1,21 @@ +/* Core */ +import * as Redux from 'redux'; +import { createLogger } from 'redux-logger'; + +const middleware: Redux.Middleware[] = [ + createLogger({ + duration: true, + timestamp: true, + collapsed: true, + colors: { + title: () => '#139BFE', + prevState: () => '#1C5FAF', + action: () => '#149945', + nextState: () => '#A47104', + error: () => '#ff0005', + }, + predicate: () => typeof window !== 'undefined', + }), +]; + +export { middleware }; diff --git a/src/website/lib/redux/rootReducer.ts b/src/website/lib/redux/rootReducer.ts new file mode 100644 index 000000000..0ac7b0faf --- /dev/null +++ b/src/website/lib/redux/rootReducer.ts @@ -0,0 +1,23 @@ +/* Instruments */ +import { combineReducers } from 'redux'; +import GPSReducer from '@/stores/GPS/GPSReducers'; +import GlobalPathReducer from '@/stores/GlobalPath/GlobalPathReducers'; +import AISShipsReducer from '@/stores/AISShips/AISShipsReducers'; +import LocalPathReducer from '@/stores/LocalPath/LocalPathReducers'; +import BatteriesReducer from '@/stores/Batteries/BatteriesReducers'; +import WindSensorsReducer from '@/stores/WindSensors/WindSensorsReducers'; +import GenericSensorsReducer from '@/stores/GenericSensors/GenericSensorsReducers'; + +export function rootReducer() { + const reducerMap = { + gps: new GPSReducer().reducer, + aisShips: new AISShipsReducer().reducer, + localPath: new LocalPathReducer().reducer, + globalPath: new GlobalPathReducer().reducer, + batteries: new BatteriesReducer().reducer, + windSensors: new WindSensorsReducer().reducer, + genericSensors: new GenericSensorsReducer().reducer, + }; + + return combineReducers(reducerMap); +} diff --git a/src/website/lib/redux/rootSaga.ts b/src/website/lib/redux/rootSaga.ts new file mode 100644 index 000000000..8b1e97399 --- /dev/null +++ b/src/website/lib/redux/rootSaga.ts @@ -0,0 +1,26 @@ +import { ForkEffect, all } from 'redux-saga/effects'; +import AISShipsSagas from '@/stores/AISShips/AISShipsSagas'; +import GPSSagas from '@/stores/GPS/GPSSagas'; +import LocalPathSagas from '@/stores/LocalPath/LocalPathSagas'; +import GlobalPathSagas from '@/stores/GlobalPath/GlobalPathSagas'; +import BatteriesSagas from '@/stores/Batteries/BatteriesSagas'; +import WindSensorsSagas from '@/stores/WindSensors/WindSensorsSagas'; +import GenericSensorsSagas from '@/stores/GenericSensors/GenericSensorsSagas'; + +export function* rootSaga() { + const rootSagaMap = { + gps: new GPSSagas().forkSagas(), + aisShips: new AISShipsSagas().forkSagas(), + localPath: new LocalPathSagas().forkSagas(), + globalPath: new GlobalPathSagas().forkSagas(), + batteries: new BatteriesSagas().forkSagas(), + windSensors: new WindSensorsSagas().forkSagas(), + genericSensors: new GenericSensorsSagas().forkSagas(), + }; + + yield all(combineSagas(rootSagaMap)); +} + +function combineSagas(sagaMap: { [s: string]: ForkEffect[] }) { + return Object.values(sagaMap).reduce((acc, arr) => acc.concat(arr), []); +} diff --git a/src/website/lib/redux/store.ts b/src/website/lib/redux/store.ts new file mode 100644 index 000000000..9ef34b8d0 --- /dev/null +++ b/src/website/lib/redux/store.ts @@ -0,0 +1,48 @@ +/* Core */ +import { applyMiddleware } from 'redux'; +import { + configureStore, + type ThunkAction, + type Action, +} from '@reduxjs/toolkit'; +import { + useSelector as useReduxSelector, + useDispatch as useReduxDispatch, + type TypedUseSelectorHook, +} from 'react-redux'; +import createSagaMiddleware from 'redux-saga'; + +/* Instruments */ +import { rootReducer } from './rootReducer'; +import { middleware } from './middleware'; +import { rootSaga } from './rootSaga'; + +const createReduxStore = () => { + const sagaMiddleWare = createSagaMiddleware(); + const store = configureStore({ + reducer: rootReducer(), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat([sagaMiddleWare, ...middleware]), + enhancers: [applyMiddleware(sagaMiddleWare)], + }); + + sagaMiddleWare.run(rootSaga); + + return store; +}; + +export const reduxStore = createReduxStore(); + +export const useDispatch = () => useReduxDispatch(); +export const useSelector: TypedUseSelectorHook = useReduxSelector; + +/* Types */ +export type ReduxStore = typeof reduxStore; +export type ReduxState = ReturnType; +export type ReduxDispatch = typeof reduxStore.dispatch; +export type ReduxThunkAction = ThunkAction< + ReturnType, + ReduxState, + unknown, + Action +>; diff --git a/src/website/models/AISShips.ts b/src/website/models/AISShips.ts new file mode 100644 index 000000000..2c1c5ddfa --- /dev/null +++ b/src/website/models/AISShips.ts @@ -0,0 +1,52 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +interface AISShip extends mongoose.Document { + id: number; + latitude: mongoose.Types.Decimal128; + longitude: mongoose.Types.Decimal128; + cog: mongoose.Types.Decimal128; + rot: mongoose.Types.Decimal128; + sog: mongoose.Types.Decimal128; + width: mongoose.Types.Decimal128; + length: mongoose.Types.Decimal128; +} + +export interface AISShips extends mongoose.Document { + ships: AISShip[]; + timestamp: string; +} + +const AISShipsSchema = new mongoose.Schema({ + ships: { + type: [ + { + id: Number, + latitude: mongoose.Types.Decimal128, + longitude: mongoose.Types.Decimal128, + cog: mongoose.Types.Decimal128, + rot: mongoose.Types.Decimal128, + sog: mongoose.Types.Decimal128, + width: mongoose.Types.Decimal128, + length: mongoose.Types.Decimal128, + }, + ], + required: [true, 'Missing array of objects in AISShips interface'], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +AISShipsSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.AISShips || + mongoose.model('AISShips', AISShipsSchema); diff --git a/src/website/models/Batteries.ts b/src/website/models/Batteries.ts new file mode 100644 index 000000000..e193ec892 --- /dev/null +++ b/src/website/models/Batteries.ts @@ -0,0 +1,48 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +interface Battery extends mongoose.Document { + voltage: mongoose.Types.Decimal128; + current: mongoose.Types.Decimal128; +} + +export interface Batteries extends mongoose.Document { + batteries: Battery[]; + timestamp: string; +} + +const BatteriesSchema = new mongoose.Schema({ + batteries: { + type: [ + { + voltage: mongoose.Types.Decimal128, + current: mongoose.Types.Decimal128, + }, + ], + required: [true, 'Missing array of objects in Batteries interface'], + validate: [ + validateArrayLimit, + 'The array length of {PATH} should equal to 2.', + ], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +function validateArrayLimit(val: any) { + return val.length == 2; +} + +BatteriesSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.Batteries || + mongoose.model('Batteries', BatteriesSchema); diff --git a/src/website/models/DesiredHeading.ts b/src/website/models/DesiredHeading.ts new file mode 100644 index 000000000..fa37f8117 --- /dev/null +++ b/src/website/models/DesiredHeading.ts @@ -0,0 +1,25 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +export interface DesiredHeading extends mongoose.Document { + heading: mongoose.Types.Decimal128; +} + +const DesiredHeadingSchema = new mongoose.Schema({ + heading: { + type: mongoose.Types.Decimal128, + required: [true, "Missing 'heading' field in DesiredHeading interface"], + }, +}); + +DesiredHeadingSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.DesiredHeading || + mongoose.model('DesiredHeading', DesiredHeadingSchema); diff --git a/src/website/models/GPS.ts b/src/website/models/GPS.ts new file mode 100644 index 000000000..1ecc0c6f6 --- /dev/null +++ b/src/website/models/GPS.ts @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +export interface GPS extends mongoose.Document { + latitude: mongoose.Types.Decimal128; + longitude: mongoose.Types.Decimal128; + speed: mongoose.Types.Decimal128; + heading: mongoose.Types.Decimal128; + timestamp: string; +} + +const GPSSchema = new mongoose.Schema({ + latitude: { + type: mongoose.Types.Decimal128, + required: [true, "Missing 'latitude' field in GPS interface"], + }, + longitude: { + type: mongoose.Types.Decimal128, + required: [true, "Missing 'longitude' field in GPS interface"], + }, + speed: { + type: mongoose.Types.Decimal128, + required: [true, "Missing 'speed' field in GPS interface"], + }, + heading: { + type: mongoose.Types.Decimal128, + required: [true, "Missing 'heading' field in GPS interface"], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +GPSSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.GPS || mongoose.model('GPS', GPSSchema); diff --git a/src/website/models/GenericSensors.ts b/src/website/models/GenericSensors.ts new file mode 100644 index 000000000..db35d9342 --- /dev/null +++ b/src/website/models/GenericSensors.ts @@ -0,0 +1,38 @@ +import mongoose from 'mongoose'; + +interface GenericSensor extends mongoose.Document { + id: number; + data: bigint; +} + +export interface GenericSensors extends mongoose.Document { + genericSensors: GenericSensor[]; + timestamp: string; +} + +const GenericSensorsSchema = new mongoose.Schema({ + genericSensors: { + type: [ + { + id: Number, + data: BigInt, + }, + ], + required: [true, 'Missing array of objects in GenericSensors interface'], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +/* Convert BigInt to a string */ +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; +(BigInt.prototype as any).toBSON = function () { + return this.toString(); +}; + +export default mongoose.models.GenericSensors || + mongoose.model('GenericSensors', GenericSensorsSchema); diff --git a/src/website/models/GlobalPath.ts b/src/website/models/GlobalPath.ts new file mode 100644 index 000000000..a6a2c14ed --- /dev/null +++ b/src/website/models/GlobalPath.ts @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +interface WayPoint extends mongoose.Document { + latitude: mongoose.Types.Decimal128; + longitude: mongoose.Types.Decimal128; +} + +export interface GlobalPath extends mongoose.Document { + waypoints: WayPoint[]; + timestamp: string; +} + +const GlobalPathSchema = new mongoose.Schema({ + waypoints: { + type: [ + { + latitude: mongoose.Types.Decimal128, + longitude: mongoose.Types.Decimal128, + }, + ], + required: [true, 'Missing array of objects in GlobalPath interface'], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +GlobalPathSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.GlobalPath || + mongoose.model('GlobalPath', GlobalPathSchema); diff --git a/src/website/models/LocalPath.ts b/src/website/models/LocalPath.ts new file mode 100644 index 000000000..1bfff14c7 --- /dev/null +++ b/src/website/models/LocalPath.ts @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +interface WayPoint extends mongoose.Document { + latitude: mongoose.Types.Decimal128; + longitude: mongoose.Types.Decimal128; +} + +export interface LocalPath extends mongoose.Document { + waypoints: WayPoint[]; + timestamp: string; +} + +const LocalPathSchema = new mongoose.Schema({ + waypoints: { + type: [ + { + latitude: mongoose.Types.Decimal128, + longitude: mongoose.Types.Decimal128, + }, + ], + required: [true, 'Missing array of objects in LocalPath interface'], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +LocalPathSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.LocalPath || + mongoose.model('LocalPath', LocalPathSchema); diff --git a/src/website/models/WindSensors.ts b/src/website/models/WindSensors.ts new file mode 100644 index 000000000..de8460530 --- /dev/null +++ b/src/website/models/WindSensors.ts @@ -0,0 +1,48 @@ +import mongoose from 'mongoose'; + +import { decimal2JSON } from './helper/parser'; + +interface WindSensor extends mongoose.Document { + speed: mongoose.Types.Decimal128; + direction: number; +} + +export interface WindSensors extends mongoose.Document { + windSensors: WindSensor[]; + timestamp: string; +} + +const WindSensorsSchema = new mongoose.Schema({ + windSensors: { + type: [ + { + speed: mongoose.Types.Decimal128, + direction: Number, + }, + ], + required: [true, 'Missing array of objects in WindSensors interface'], + validate: [ + validateArrayLimit, + 'The array length of {PATH} should equal to 2.', + ], + }, + timestamp: { + type: String, + default: () => new Date().toISOString(), + }, +}); + +function validateArrayLimit(val: any) { + return val.length == 2; +} + +WindSensorsSchema.set('toJSON', { + transform: (doc, ret) => { + // @ts-ignore: Expected 3 arguments, but got 1 + decimal2JSON(ret); + return ret; + }, +}); + +export default mongoose.models.WindSensors || + mongoose.model('WindSensors', WindSensorsSchema); diff --git a/src/website/models/helper/parser.ts b/src/website/models/helper/parser.ts new file mode 100644 index 000000000..4518b5fc9 --- /dev/null +++ b/src/website/models/helper/parser.ts @@ -0,0 +1,9 @@ +export const decimal2JSON = (v, i, prev) => { + if (v !== null && typeof v === 'object') { + if (v.constructor.name === 'Decimal128') prev[i] = parseFloat(v.toString()); + else + Object.entries(v).forEach(([key, value]) => + decimal2JSON(value, key, prev ? prev[i] : v), + ); + } +}; diff --git a/src/website/package-lock.json b/src/website/package-lock.json new file mode 100644 index 000000000..47bd58f4a --- /dev/null +++ b/src/website/package-lock.json @@ -0,0 +1,8459 @@ +{ + "name": "website", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/material": "^5.14.12", + "@react-google-maps/api": "^2.19.2", + "@reduxjs/toolkit": "^1.9.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", + "leaflet-geometryutil": "^0.10.2", + "mongodb": "^4.8.1", + "mongoose": "^7.5.0", + "next": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-redux": "^8.1.2", + "recharts": "^2.10.4", + "redux": "^4.2.1", + "redux-logger": "^3.0.6", + "redux-saga": "^1.2.3", + "redux-thunk": "^2.4.2", + "uplot": "^1.6.30", + "uplot-react": "^1.1.5" + }, + "devDependencies": { + "@types/express": "^4.17.17", + "@types/leaflet": "^1.9.7", + "@types/node": "18.7.5", + "@types/react": "^18.2.37", + "@types/redux-logger": "^3.0.12", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "eslint": "^8.53.0", + "eslint-config-next": "14.0.2", + "eslint-config-prettier": "^9.0.0", + "prettier": "^3.1.0", + "sass": "^1.69.5", + "stylelint": "^15.11.0", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^11.1.0", + "typescript": "4.6.3" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "3.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD", + "optional": true + }, + "node_modules/@aws-crypto/ie11-detection": { + "version": "3.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD", + "optional": true + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "3.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD", + "optional": true + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "3.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD", + "optional": true + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "3.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD", + "optional": true + }, + "node_modules/@aws-crypto/util": { + "version": "3.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD", + "optional": true + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.398.0", + "@aws-sdk/credential-provider-node": "3.398.0", + "@aws-sdk/middleware-host-header": "3.398.0", + "@aws-sdk/middleware-logger": "3.398.0", + "@aws-sdk/middleware-recursion-detection": "3.398.0", + "@aws-sdk/middleware-signing": "3.398.0", + "@aws-sdk/middleware-user-agent": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@aws-sdk/util-endpoints": "3.398.0", + "@aws-sdk/util-user-agent-browser": "3.398.0", + "@aws-sdk/util-user-agent-node": "3.398.0", + "@smithy/config-resolver": "^2.0.5", + "@smithy/fetch-http-handler": "^2.0.5", + "@smithy/hash-node": "^2.0.5", + "@smithy/invalid-dependency": "^2.0.5", + "@smithy/middleware-content-length": "^2.0.5", + "@smithy/middleware-endpoint": "^2.0.5", + "@smithy/middleware-retry": "^2.0.5", + "@smithy/middleware-serde": "^2.0.5", + "@smithy/middleware-stack": "^2.0.0", + "@smithy/node-config-provider": "^2.0.5", + "@smithy/node-http-handler": "^2.0.5", + "@smithy/protocol-http": "^2.0.5", + "@smithy/smithy-client": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/url-parser": "^2.0.5", + "@smithy/util-base64": "^2.0.0", + "@smithy/util-body-length-browser": "^2.0.0", + "@smithy/util-body-length-node": "^2.1.0", + "@smithy/util-defaults-mode-browser": "^2.0.5", + "@smithy/util-defaults-mode-node": "^2.0.5", + "@smithy/util-retry": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/middleware-host-header": "3.398.0", + "@aws-sdk/middleware-logger": "3.398.0", + "@aws-sdk/middleware-recursion-detection": "3.398.0", + "@aws-sdk/middleware-user-agent": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@aws-sdk/util-endpoints": "3.398.0", + "@aws-sdk/util-user-agent-browser": "3.398.0", + "@aws-sdk/util-user-agent-node": "3.398.0", + "@smithy/config-resolver": "^2.0.5", + "@smithy/fetch-http-handler": "^2.0.5", + "@smithy/hash-node": "^2.0.5", + "@smithy/invalid-dependency": "^2.0.5", + "@smithy/middleware-content-length": "^2.0.5", + "@smithy/middleware-endpoint": "^2.0.5", + "@smithy/middleware-retry": "^2.0.5", + "@smithy/middleware-serde": "^2.0.5", + "@smithy/middleware-stack": "^2.0.0", + "@smithy/node-config-provider": "^2.0.5", + "@smithy/node-http-handler": "^2.0.5", + "@smithy/protocol-http": "^2.0.5", + "@smithy/smithy-client": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/url-parser": "^2.0.5", + "@smithy/util-base64": "^2.0.0", + "@smithy/util-body-length-browser": "^2.0.0", + "@smithy/util-body-length-node": "^2.1.0", + "@smithy/util-defaults-mode-browser": "^2.0.5", + "@smithy/util-defaults-mode-node": "^2.0.5", + "@smithy/util-retry": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/credential-provider-node": "3.398.0", + "@aws-sdk/middleware-host-header": "3.398.0", + "@aws-sdk/middleware-logger": "3.398.0", + "@aws-sdk/middleware-recursion-detection": "3.398.0", + "@aws-sdk/middleware-sdk-sts": "3.398.0", + "@aws-sdk/middleware-signing": "3.398.0", + "@aws-sdk/middleware-user-agent": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@aws-sdk/util-endpoints": "3.398.0", + "@aws-sdk/util-user-agent-browser": "3.398.0", + "@aws-sdk/util-user-agent-node": "3.398.0", + "@smithy/config-resolver": "^2.0.5", + "@smithy/fetch-http-handler": "^2.0.5", + "@smithy/hash-node": "^2.0.5", + "@smithy/invalid-dependency": "^2.0.5", + "@smithy/middleware-content-length": "^2.0.5", + "@smithy/middleware-endpoint": "^2.0.5", + "@smithy/middleware-retry": "^2.0.5", + "@smithy/middleware-serde": "^2.0.5", + "@smithy/middleware-stack": "^2.0.0", + "@smithy/node-config-provider": "^2.0.5", + "@smithy/node-http-handler": "^2.0.5", + "@smithy/protocol-http": "^2.0.5", + "@smithy/smithy-client": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/url-parser": "^2.0.5", + "@smithy/util-base64": "^2.0.0", + "@smithy/util-body-length-browser": "^2.0.0", + "@smithy/util-body-length-node": "^2.1.0", + "@smithy/util-defaults-mode-browser": "^2.0.5", + "@smithy/util-defaults-mode-node": "^2.0.5", + "@smithy/util-retry": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "3.398.0", + "@aws-sdk/credential-provider-process": "3.398.0", + "@aws-sdk/credential-provider-sso": "3.398.0", + "@aws-sdk/credential-provider-web-identity": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@smithy/credential-provider-imds": "^2.0.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/shared-ini-file-loader": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "3.398.0", + "@aws-sdk/credential-provider-ini": "3.398.0", + "@aws-sdk/credential-provider-process": "3.398.0", + "@aws-sdk/credential-provider-sso": "3.398.0", + "@aws-sdk/credential-provider-web-identity": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@smithy/credential-provider-imds": "^2.0.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/shared-ini-file-loader": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/shared-ini-file-loader": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/client-sso": "3.398.0", + "@aws-sdk/token-providers": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/shared-ini-file-loader": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.398.0", + "@aws-sdk/client-sso": "3.398.0", + "@aws-sdk/client-sts": "3.398.0", + "@aws-sdk/credential-provider-cognito-identity": "3.398.0", + "@aws-sdk/credential-provider-env": "3.398.0", + "@aws-sdk/credential-provider-ini": "3.398.0", + "@aws-sdk/credential-provider-node": "3.398.0", + "@aws-sdk/credential-provider-process": "3.398.0", + "@aws-sdk/credential-provider-sso": "3.398.0", + "@aws-sdk/credential-provider-web-identity": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@smithy/credential-provider-imds": "^2.0.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/protocol-http": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/protocol-http": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sts": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/middleware-signing": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-signing": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/property-provider": "^2.0.0", + "@smithy/protocol-http": "^2.0.5", + "@smithy/signature-v4": "^2.0.0", + "@smithy/types": "^2.2.2", + "@smithy/util-middleware": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@aws-sdk/util-endpoints": "3.398.0", + "@smithy/protocol-http": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/middleware-host-header": "3.398.0", + "@aws-sdk/middleware-logger": "3.398.0", + "@aws-sdk/middleware-recursion-detection": "3.398.0", + "@aws-sdk/middleware-user-agent": "3.398.0", + "@aws-sdk/types": "3.398.0", + "@aws-sdk/util-endpoints": "3.398.0", + "@aws-sdk/util-user-agent-browser": "3.398.0", + "@aws-sdk/util-user-agent-node": "3.398.0", + "@smithy/config-resolver": "^2.0.5", + "@smithy/fetch-http-handler": "^2.0.5", + "@smithy/hash-node": "^2.0.5", + "@smithy/invalid-dependency": "^2.0.5", + "@smithy/middleware-content-length": "^2.0.5", + "@smithy/middleware-endpoint": "^2.0.5", + "@smithy/middleware-retry": "^2.0.5", + "@smithy/middleware-serde": "^2.0.5", + "@smithy/middleware-stack": "^2.0.0", + "@smithy/node-config-provider": "^2.0.5", + "@smithy/node-http-handler": "^2.0.5", + "@smithy/property-provider": "^2.0.0", + "@smithy/protocol-http": "^2.0.5", + "@smithy/shared-ini-file-loader": "^2.0.0", + "@smithy/smithy-client": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/url-parser": "^2.0.5", + "@smithy/util-base64": "^2.0.0", + "@smithy/util-body-length-browser": "^2.0.0", + "@smithy/util-body-length-node": "^2.1.0", + "@smithy/util-defaults-mode-browser": "^2.0.5", + "@smithy/util-defaults-mode-node": "^2.0.5", + "@smithy/util-retry": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.310.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/types": "^2.2.2", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.398.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/node-config-provider": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.10", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.22.10", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.5", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.10", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.5", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz", + "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.22.10", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.5", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.3.2.tgz", + "integrity": "sha512-sLYGdAdEY2x7TSw9FtmdaTrh2wFtRJO5VMbBrA8tEqEod7GEggFmxTSK9XqExib3yMuYNcvcTdCZIP6ukdjAIA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^2.2.1" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.1.tgz", + "integrity": "sha512-Zmsf2f/CaEPWEVgw29odOj+WEVoiJy9s9NOv5GgNY9mZ1CZ7394By6wONrONrTsnNDv6F9hR02nvFihrGVGHBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.5.tgz", + "integrity": "sha512-IxVBdYzR8pYe89JiyXQuYk4aVVoCPhMJkz6ElRwlVysjwURTsTk/bmY/z4FfeRE+CRBMlykPwXEVUg8lThv7AQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.3.2", + "@csstools/css-tokenizer": "^2.2.1" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.0.0.tgz", + "integrity": "sha512-hBI9tfBtuPIi885ZsZ32IMEU/5nlZH/KOVYJCOh7gyMxaVLGmLedYqFN6Ui1LXkI8JlC8IsuC0rF0btcRZKd5g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.11.0", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.11.0", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.1", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.11.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.2", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.11.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.1", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", + "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@eslint/js": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", + "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "dependencies": { + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "dependencies": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.2.tgz", + "integrity": "sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==", + "dependencies": { + "@floating-ui/dom": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.2", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.3.2", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", + "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", + "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "dev": true + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.19.tgz", + "integrity": "sha512-maNBgAscddyPNzFZQUJDF/puxM27Li+NqSBsr/lAP8TLns2VvWS2SoL3OKFOIoRnAMKGY/Ic6Aot6gCYeQnssA==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@floating-ui/react-dom": "^2.0.2", + "@mui/types": "^7.2.6", + "@mui/utils": "^5.14.13", + "@popperjs/core": "^2.11.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.13.tgz", + "integrity": "sha512-3ZUbzcH4yloLKlV6Y+S0Edn2wef9t+EGHSfEkwVCn8E0ULdshifEFgfEroKRegQifDIwcKS/ofccxuZ8njTAYg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + } + }, + "node_modules/@mui/material": { + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.14.13.tgz", + "integrity": "sha512-iPEFwhoVG789UVsXX4gqd1eJUlcLW1oceqwJYQN8Z4MpcAKfL9Lv3fda65AwG7pQ5lf+d7IbHzm4m48SWZxI2g==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@mui/base": "5.0.0-beta.19", + "@mui/core-downloads-tracker": "^5.14.13", + "@mui/system": "^5.14.13", + "@mui/types": "^7.2.6", + "@mui/utils": "^5.14.13", + "@types/react-transition-group": "^4.4.7", + "clsx": "^2.0.0", + "csstype": "^3.1.2", + "prop-types": "^15.8.1", + "react-is": "^18.2.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, + "node_modules/@mui/private-theming": { + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.13.tgz", + "integrity": "sha512-5EFqk4tqiSwPguj4NW/6bUf4u1qoUWXy9lrKfNh9H6oAohM+Ijv/7qSxFjnxPGBctj469/Sc5aKAR35ILBKZLQ==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@mui/utils": "^5.14.13", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.13.tgz", + "integrity": "sha512-1ff/egFQl26hiwcUtCMKAkp4Sgqpm3qIewmXq+GN27fb44lDIACquehMFBuadOjceOFmbIXbayzbA46ZyqFYzA==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@emotion/cache": "^11.11.0", + "csstype": "^3.1.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.14.13.tgz", + "integrity": "sha512-+5+Dx50lG4csbx2sGjrKLozXQJeCpJ4dIBZolyFLkZ+XphD1keQWouLUvJkPQ3MSglLLKuD37pp52YjMncZMEQ==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@mui/private-theming": "^5.14.13", + "@mui/styled-engine": "^5.14.13", + "@mui/types": "^7.2.6", + "@mui/utils": "^5.14.13", + "clsx": "^2.0.0", + "csstype": "^3.1.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.6.tgz", + "integrity": "sha512-7sjLQrUmBwufm/M7jw/quNiPK/oor2+pGUQP2CULRcFCArYTq78oJ3D5esTaL0UMkXKJvDqXn6Ike69yAOBQng==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.14.13", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.13.tgz", + "integrity": "sha512-2AFpyXWw7uDCIqRu7eU2i/EplZtks5LAMzQvIhC79sPV9IhOZU2qwOWVnPtdctRXiQJOAaXulg+A37pfhEueQw==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@types/prop-types": "^15.7.7", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, + "node_modules/@next/env": { + "version": "13.4.19", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.2.tgz", + "integrity": "sha512-APrYFsXfAhnysycqxHcpg6Y4i7Ukp30GzVSZQRKT3OczbzkqGjt33vNhScmgoOXYBU1CfkwgtXmNxdiwv1jKmg==", + "dev": true, + "dependencies": { + "glob": "7.1.7" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.19.tgz", + "integrity": "sha512-vv1qrjXeGbuF2mOkhkdxMDtv9np7W4mcBtaDnHU+yJG+bBwa6rYsYSCI/9Xm5+TuF5SbZbrWO6G1NfTh1TMjvQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz", + "integrity": "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.19.tgz", + "integrity": "sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.19.tgz", + "integrity": "sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.19.tgz", + "integrity": "sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.19.tgz", + "integrity": "sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.19.tgz", + "integrity": "sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.19.tgz", + "integrity": "sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.19.tgz", + "integrity": "sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-google-maps/api": { + "version": "2.19.2", + "license": "MIT", + "dependencies": { + "@googlemaps/js-api-loader": "1.16.2", + "@googlemaps/markerclusterer": "2.3.2", + "@react-google-maps/infobox": "2.19.2", + "@react-google-maps/marker-clusterer": "2.19.2", + "@types/google.maps": "3.53.5", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + } + }, + "node_modules/@react-google-maps/infobox": { + "version": "2.19.2", + "license": "MIT" + }, + "node_modules/@react-google-maps/marker-clusterer": { + "version": "2.19.2", + "license": "MIT" + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@redux-saga/core": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@redux-saga/deferred": "^1.2.1", + "@redux-saga/delay-p": "^1.2.1", + "@redux-saga/is": "^1.1.3", + "@redux-saga/symbols": "^1.1.3", + "@redux-saga/types": "^1.2.1", + "redux": "^4.0.4", + "typescript-tuple": "^2.2.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/redux-saga" + } + }, + "node_modules/@redux-saga/deferred": { + "version": "1.2.1", + "license": "MIT" + }, + "node_modules/@redux-saga/delay-p": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "@redux-saga/symbols": "^1.1.3" + } + }, + "node_modules/@redux-saga/is": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "@redux-saga/symbols": "^1.1.3", + "@redux-saga/types": "^1.2.1" + } + }, + "node_modules/@redux-saga/symbols": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/@redux-saga/types": { + "version": "1.2.1", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.5", + "license": "MIT", + "dependencies": { + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.5.1.tgz", + "integrity": "sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==", + "dev": true + }, + "node_modules/@smithy/abort-controller": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "@smithy/util-config-provider": "^2.0.0", + "@smithy/util-middleware": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/node-config-provider": "^2.0.5", + "@smithy/property-provider": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/url-parser": "^2.0.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@smithy/types": "^2.2.2", + "@smithy/util-hex-encoding": "^2.0.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/protocol-http": "^2.0.5", + "@smithy/querystring-builder": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/util-base64": "^2.0.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "@smithy/util-buffer-from": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/protocol-http": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/middleware-serde": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/url-parser": "^2.0.5", + "@smithy/util-middleware": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/protocol-http": "^2.0.5", + "@smithy/service-error-classification": "^2.0.0", + "@smithy/types": "^2.2.2", + "@smithy/util-middleware": "^2.0.0", + "@smithy/util-retry": "^2.0.0", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/property-provider": "^2.0.5", + "@smithy/shared-ini-file-loader": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/abort-controller": "^2.0.5", + "@smithy/protocol-http": "^2.0.5", + "@smithy/querystring-builder": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "@smithy/util-uri-escape": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/eventstream-codec": "^2.0.5", + "@smithy/is-array-buffer": "^2.0.0", + "@smithy/types": "^2.2.2", + "@smithy/util-hex-encoding": "^2.0.0", + "@smithy/util-middleware": "^2.0.0", + "@smithy/util-uri-escape": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/middleware-stack": "^2.0.0", + "@smithy/types": "^2.2.2", + "@smithy/util-stream": "^2.0.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "2.2.2", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/querystring-parser": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "2.1.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/property-provider": "^2.0.5", + "@smithy/types": "^2.2.2", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/config-resolver": "^2.0.5", + "@smithy/credential-provider-imds": "^2.0.5", + "@smithy/node-config-provider": "^2.0.5", + "@smithy/property-provider": "^2.0.5", + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/service-error-classification": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "2.0.5", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/fetch-http-handler": "^2.0.5", + "@smithy/node-http-handler": "^2.0.5", + "@smithy/types": "^2.2.2", + "@smithy/util-base64": "^2.0.0", + "@smithy/util-buffer-from": "^2.0.0", + "@smithy/util-hex-encoding": "^2.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.0.0", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.2.tgz", + "integrity": "sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/express": { + "version": "4.17.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.36", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.12", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.12.tgz", + "integrity": "sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA==", + "dev": true + }, + "node_modules/@types/google.maps": { + "version": "3.53.5", + "license": "MIT" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/leaflet": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.7.tgz", + "integrity": "sha512-FOfKB1ALYUDnXkH7LfTFreWiZr9R7GErqGP+8lYQGWr2GFq5+jy3Ih0M7e9j41cvRN65kLALJ4dc43yZwyl/6g==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true + }, + "node_modules/@types/node": { + "version": "18.7.5", + "license": "MIT" + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, + "node_modules/@types/parse-json": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.8", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.8.tgz", + "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==" + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.2.37", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.37.tgz", + "integrity": "sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.7.tgz", + "integrity": "sha512-ICCyBl5mvyqYp8Qeq9B5G/fyBSRC0zx3XM3sCC6KkcMsNeAHqXBKkmat4GqdJET5jtYUpZXrxI5flve5qhi2Eg==", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/redux-logger": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.12.tgz", + "integrity": "sha512-5vAlwokZi/Unb1eGoZfVVzIBTPNDflwXiDzPLT1SynP6hdJfsOEf+w6ZOySOyboLWciCRYeE5DGYUnwVCq+Uyg==", + "dev": true, + "dependencies": { + "redux": "^4.0.0" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.6.tgz", + "integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA==" + }, + "node_modules/@types/semver": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz", + "integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.0", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.10.0.tgz", + "integrity": "sha512-uoLj4g2OTL8rfUQVx2AFO1hp/zja1wABJq77P6IclQs6I/m9GLrm7jCdgzZkvWdDCQf1uEvoa8s8CupsgWQgVg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.10.0", + "@typescript-eslint/type-utils": "6.10.0", + "@typescript-eslint/utils": "6.10.0", + "@typescript-eslint/visitor-keys": "6.10.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.10.0.tgz", + "integrity": "sha512-+sZwIj+s+io9ozSxIWbNB5873OSdfeBEH/FR0re14WLI6BaKuSOnnwCJ2foUiu8uXf4dRp1UqHP0vrZ1zXGrog==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.10.0", + "@typescript-eslint/types": "6.10.0", + "@typescript-eslint/typescript-estree": "6.10.0", + "@typescript-eslint/visitor-keys": "6.10.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.10.0.tgz", + "integrity": "sha512-TN/plV7dzqqC2iPNf1KrxozDgZs53Gfgg5ZHyw8erd6jd5Ta/JIEcdCheXFt9b1NYb93a1wmIIVW/2gLkombDg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.10.0", + "@typescript-eslint/visitor-keys": "6.10.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.10.0.tgz", + "integrity": "sha512-wYpPs3hgTFblMYwbYWPT3eZtaDOjbLyIYuqpwuLBBqhLiuvJ+9sEp2gNRJEtR5N/c9G1uTtQQL5AhV0fEPJYcg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.10.0", + "@typescript-eslint/utils": "6.10.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/types": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.10.0.tgz", + "integrity": "sha512-36Fq1PWh9dusgo3vH7qmQAj5/AZqARky1Wi6WpINxB6SkQdY5vQoT2/7rW7uBIsPDcvvGCLi4r10p0OJ7ITAeg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.10.0.tgz", + "integrity": "sha512-ek0Eyuy6P15LJVeghbWhSrBCj/vJpPXXR+EpaRZqou7achUWL8IdYnMSC5WHAeTWswYQuP2hAZgij/bC9fanBg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.10.0", + "@typescript-eslint/visitor-keys": "6.10.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.10.0.tgz", + "integrity": "sha512-v+pJ1/RcVyRc0o4wAGux9x42RHmAjIGzPRo538Z8M1tVx6HOnoQBCX/NoadHQlZeC+QO2yr4nNSFWOoraZCAyg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.10.0", + "@typescript-eslint/types": "6.10.0", + "@typescript-eslint/typescript-estree": "6.10.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.10.0.tgz", + "integrity": "sha512-xMGluxQIEtOM7bqFCo+rCMh5fqI+ZxV5RUUOa29iVPz1OgCZrtc7rFnz5cLUazlkPKYqX+75iuDq7m0HQ48nCg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.10.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", + "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", + "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", + "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/body-parser": { + "version": "1.20.1", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.11.0", + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "4.7.2", + "license": "Apache-2.0", + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "dev": true, + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001522", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk/node_modules/escape-string-regexp": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-functions-list": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.1.tgz", + "integrity": "sha512-Nj5YcaGgBtuUmn1D7oHqPW0c9iui7xsTsj5lIX8ZgevdfhmjFfKB3r8moHJtNJnctnYXJyYX5I1pp90HM4TPgQ==", + "dev": true, + "engines": { + "node": ">=12 || >=16" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "node_modules/deep-diff": { + "version": "0.3.8", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz", + "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.3", + "@eslint/js": "8.53.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.2.tgz", + "integrity": "sha512-CasWThlsyIcg/a+clU6KVOMTieuDhTztsrqvniP6AsRki9v7FnojTa7vKQOYM8QSOsQdZ/aElLD1Y2Oc8/PsIg==", + "dev": true, + "dependencies": { + "@next/eslint-plugin-next": "14.0.2", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", + "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", + "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/eslint-module-utils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/eslint-plugin-import": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", + "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.2", + "aria-query": "^5.3.0", + "array-includes": "^3.1.7", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "=4.7.0", + "axobject-query": "^3.2.1", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "es-iterator-helpers": "^1.0.15", + "hasown": "^2.0.0", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.entries": "^1.1.7", + "object.fromentries": "^2.0.7" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.4", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.8" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", + "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/express": { + "version": "4.18.2", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-xml-parser": { + "version": "4.2.5", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", + "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "license": "BSD-2-Clause" + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", + "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ip": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/known-css-properties": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", + "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", + "dev": true + }, + "node_modules/language-subtag-registry": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, + "node_modules/leaflet-defaulticon-compatibility": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.2.tgz", + "integrity": "sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q==" + }, + "node_modules/leaflet-geometryutil": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/leaflet-geometryutil/-/leaflet-geometryutil-0.10.2.tgz", + "integrity": "sha512-NBKO0dW6+Ur0LKa6t5JMBd242lAMjRek23O8awlpvoDmTrb7fqgZC608YLwpZ4TYJZ/RbLu9fclIN8M4pIVwTQ==", + "dependencies": { + "leaflet": "^1.6.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "license": "MIT", + "optional": true + }, + "node_modules/meow": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", + "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "dev": true, + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mongodb": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.1.tgz", + "integrity": "sha512-MBuyYiPUPRTqfH2dV0ya4dcr2E5N52ocBuZ8Sgg/M030nGF78v855B3Z27mZJnp8PxjnUquEnAtjOsphgMZOlQ==", + "dependencies": { + "bson": "^4.7.2", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "@mongodb-js/saslprep": "^1.1.0" + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongoose": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.5.0.tgz", + "integrity": "sha512-FpOWOb0AJuaVcplmEyIJ2eCbVGe4gOoniPD+pmft5BrGrNrsFcnYXlERdXtBApGHMHPwD7WbxTyhCbUNr72F3Q==", + "dependencies": { + "bson": "^5.4.0", + "kareem": "2.5.1", + "mongodb": "5.8.1", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.4.0.tgz", + "integrity": "sha512-WRZ5SQI5GfUuKnPTNmAYPiKIof3ORXAF4IRU5UcgmivNIon01rWQlw5RUH954dpu8yGL8T59YShVddIPaU/gFA==", + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.8.1.tgz", + "integrity": "sha512-wKyh4kZvm6NrCPH8AxyzXm3JBoEf4Xulo0aUWh3hCgwgYJxyQ1KLST86ZZaSWdj6/kxYUA3+YZuyADCE61CMSg==", + "dependencies": { + "bson": "^5.4.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/mquery/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mquery/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.6", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/next/-/next-13.4.19.tgz", + "integrity": "sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==", + "dependencies": { + "@next/env": "13.4.19", + "@swc/helpers": "0.5.1", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "postcss": "8.4.14", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0", + "zod": "3.21.4" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=16.8.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "13.4.19", + "@next/swc-darwin-x64": "13.4.19", + "@next/swc-linux-arm64-gnu": "13.4.19", + "@next/swc-linux-arm64-musl": "13.4.19", + "@next/swc-linux-x64-gnu": "13.4.19", + "@next/swc-linux-x64-musl": "13.4.19", + "@next/swc-win32-arm64-msvc": "13.4.19", + "@next/swc-win32-ia32-msvc": "13.4.19", + "@next/swc-win32-x64-msvc": "13.4.19" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", + "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/object.hasown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.3.tgz", + "integrity": "sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true + }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz", + "integrity": "sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==", + "dev": true + }, + "node_modules/postcss-safe-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", + "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", + "dev": true, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", + "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-redux": { + "version": "8.1.2", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.2.0", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.5.tgz", + "integrity": "sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==", + "dependencies": { + "fast-equals": "^5.0.0", + "react-transition-group": "2.9.0" + }, + "peerDependencies": { + "prop-types": "^15.6.0", + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-smooth/node_modules/dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, + "node_modules/react-smooth/node_modules/react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "dependencies": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0", + "react-dom": ">=15.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-pkg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", + "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", + "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", + "dev": true, + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.11.0.tgz", + "integrity": "sha512-5s+u1m5Hwxb2nh0LABkE3TS/lFqFHyWl7FnPbQhHobbQQia4ih1t3o3+ikPYr31Ns+kYe4FASIthKeKi/YYvMg==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.19", + "react-is": "^16.10.2", + "react-smooth": "^2.0.5", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "prop-types": "^15.6.0", + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/redent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", + "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", + "dev": true, + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-logger": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "deep-diff": "^0.3.5" + } + }, + "node_modules/redux-saga": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@redux-saga/core": "^1.2.3" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "license": "MIT", + "peerDependencies": { + "redux": "^4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "4.1.8", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.4", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.69.5", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", + "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.18.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "license": "MIT", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.7.1", + "license": "MIT", + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "license": "MIT", + "optional": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", + "integrity": "sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "regexp.prototype.flags": "^1.5.0", + "set-function-name": "^2.0.0", + "side-channel": "^1.0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", + "integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.0.5", + "license": "MIT", + "optional": true + }, + "node_modules/style-search": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", + "dev": true + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylelint": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.11.0.tgz", + "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", + "dev": true, + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/media-query-list-parser": "^2.1.4", + "@csstools/selector-specificity": "^3.0.0", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^8.2.0", + "css-functions-list": "^3.2.1", + "css-tree": "^2.3.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^7.0.0", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^5.2.4", + "import-lazy": "^4.0.0", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.29.0", + "mathml-tag-names": "^2.1.3", + "meow": "^10.1.5", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.28", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "style-search": "^0.1.0", + "supports-hyperlinks": "^3.0.0", + "svg-tags": "^1.0.0", + "table": "^6.8.1", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + } + }, + "node_modules/stylelint-config-prettier-scss": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-prettier-scss/-/stylelint-config-prettier-scss-1.0.0.tgz", + "integrity": "sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==", + "dev": true, + "bin": { + "stylelint-config-prettier-scss": "bin/check.js", + "stylelint-config-prettier-scss-check": "bin/check.js" + }, + "engines": { + "node": "14.* || 16.* || >= 18" + }, + "peerDependencies": { + "stylelint": ">=15.0.0" + } + }, + "node_modules/stylelint-config-recommended": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-13.0.0.tgz", + "integrity": "sha512-EH+yRj6h3GAe/fRiyaoO2F9l9Tgg50AOFhaszyfov9v6ayXJ1IkSHwTxd7lB48FmOeSGDPLjatjO11fJpmarkQ==", + "dev": true, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "stylelint": "^15.10.0" + } + }, + "node_modules/stylelint-config-recommended-scss": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-13.1.0.tgz", + "integrity": "sha512-8L5nDfd+YH6AOoBGKmhH8pLWF1dpfY816JtGMePcBqqSsLU+Ysawx44fQSlMOJ2xTfI9yTGpup5JU77c17w1Ww==", + "dev": true, + "dependencies": { + "postcss-scss": "^4.0.9", + "stylelint-config-recommended": "^13.0.0", + "stylelint-scss": "^5.3.0" + }, + "peerDependencies": { + "postcss": "^8.3.3", + "stylelint": "^15.10.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + } + } + }, + "node_modules/stylelint-config-standard": { + "version": "34.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-34.0.0.tgz", + "integrity": "sha512-u0VSZnVyW9VSryBG2LSO+OQTjN7zF9XJaAJRX/4EwkmU0R2jYwmBSN10acqZisDitS0CLiEiGjX7+Hrq8TAhfQ==", + "dev": true, + "dependencies": { + "stylelint-config-recommended": "^13.0.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "stylelint": "^15.10.0" + } + }, + "node_modules/stylelint-config-standard-scss": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-11.1.0.tgz", + "integrity": "sha512-5gnBgeNTgRVdchMwiFQPuBOtj9QefYtfXiddrOMJA2pI22zxt6ddI2s+e5Oh7/6QYl7QLJujGnaUR5YyGq72ow==", + "dev": true, + "dependencies": { + "stylelint-config-recommended-scss": "^13.1.0", + "stylelint-config-standard": "^34.0.0" + }, + "peerDependencies": { + "postcss": "^8.3.3", + "stylelint": "^15.10.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + } + } + }, + "node_modules/stylelint-scss": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-5.3.1.tgz", + "integrity": "sha512-5I9ZDIm77BZrjOccma5WyW2nJEKjXDd4Ca8Kk+oBapSO4pewSlno3n+OyimcyVJJujQZkBN2D+xuMkIamSc6hA==", + "dev": true, + "dependencies": { + "known-css-properties": "^0.29.0", + "postcss-media-query-parser": "^0.2.3", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "stylelint": "^14.5.1 || ^15.0.0" + } + }, + "node_modules/stylelint/node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true + }, + "node_modules/stylelint/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/stylelint/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.1.tgz", + "integrity": "sha512-uLfFktPmRetVCbHe5UPuekWrQ6hENufnA46qEGbfACkK5drjTTdQYUragRgMjHldcbYG+nslUerqMPjbBSHXjQ==", + "dev": true, + "dependencies": { + "flat-cache": "^3.1.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stylelint/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz", + "integrity": "sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/table": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", + "integrity": "sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "4.6.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/typescript-compare": { + "version": "0.0.2", + "license": "MIT", + "dependencies": { + "typescript-logic": "^0.0.0" + } + }, + "node_modules/typescript-logic": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/typescript-tuple": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "typescript-compare": "^0.0.2" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uplot": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.30.tgz", + "integrity": "sha512-48oVVRALM/128ttW19F2a2xobc2WfGdJ0VJFX00099CfqbCTuML7L2OrTKxNzeFP34eo1+yJbqFSoFAp2u28/Q==" + }, + "node_modules/uplot-react": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/uplot-react/-/uplot-react-1.1.5.tgz", + "integrity": "sha512-dXw8GB75MwagtxD3MNhotGUGl43TFLqPh6mrzCW2SCiE3WiXugvAbcYfiKAoYZrM/C7Tdbg03gIaG6g7porabQ==", + "engines": { + "node": ">=8.10" + }, + "peerDependencies": { + "react": ">=16.8.6", + "uplot": "^1.6.7" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.8.4", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.8.4.tgz", + "integrity": "sha512-30dOGZVjrOraxzflyZozjwYBYnIjhX2c18kuVNiiZlRHx++8zXGptlXSAm57M87Y2WLN10XGbn8kTXntqteKUw==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.2", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.21.4", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/src/website/package.json b/src/website/package.json new file mode 100644 index 000000000..d92309816 --- /dev/null +++ b/src/website/package.json @@ -0,0 +1,54 @@ +{ + "private": true, + "scripts": { + "dev": "next dev -p 3005", + "build": "prettier --check . && stylelint --allow-empty-input \"**/*.{css,scss}\" && next build", + "start": "next start", + "lint": "prettier --check . && stylelint --allow-empty-input \"**/*.{css,scss}\" && next lint", + "format": "prettier --write ." + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/material": "^5.14.12", + "@react-google-maps/api": "^2.19.2", + "@reduxjs/toolkit": "^1.9.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", + "leaflet-geometryutil": "^0.10.2", + "mongodb": "^4.8.1", + "mongoose": "^7.5.0", + "next": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-redux": "^8.1.2", + "recharts": "^2.10.4", + "redux": "^4.2.1", + "redux-logger": "^3.0.6", + "redux-saga": "^1.2.3", + "redux-thunk": "^2.4.2", + "uplot": "^1.6.30", + "uplot-react": "^1.1.5" + }, + "devDependencies": { + "@types/express": "^4.17.17", + "@types/leaflet": "^1.9.7", + "@types/node": "18.7.5", + "@types/react": "^18.2.37", + "@types/redux-logger": "^3.0.12", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "eslint": "^8.53.0", + "eslint-config-next": "14.0.2", + "eslint-config-prettier": "^9.0.0", + "prettier": "^3.1.0", + "sass": "^1.69.5", + "stylelint": "^15.11.0", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^11.1.0", + "typescript": "4.6.3" + } +} diff --git a/src/website/pages/_app.tsx b/src/website/pages/_app.tsx new file mode 100644 index 000000000..94f66e9a9 --- /dev/null +++ b/src/website/pages/_app.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Providers } from '@/lib/providers'; +import type { AppProps } from 'next/app'; + +export default function App({ Component, pageProps }: AppProps) { + return ( + + + + ); +} diff --git a/src/website/pages/api/aisships/index.ts b/src/website/pages/api/aisships/index.ts new file mode 100644 index 000000000..9c2d0907c --- /dev/null +++ b/src/website/pages/api/aisships/index.ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import AISShips from '@/models/AISShips'; +import { AISShips as AISShipsDocument } from '@/stores/AISShips/AISShipsTypes'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const aisships: AISShipsDocument[] = await AISShips.find({}).select({ + 'ships._id': 0, + _id: 0, + __v: 0, + }); + res.status(200).json({ success: true, data: aisships }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/api/batteries/index.ts b/src/website/pages/api/batteries/index.ts new file mode 100644 index 000000000..0d310d2a6 --- /dev/null +++ b/src/website/pages/api/batteries/index.ts @@ -0,0 +1,32 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import Batteries from '@/models/Batteries'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const batteries = await Batteries.find({}).select({ + 'batteries._id': 0, + _id: 0, + __v: 0, + }); + res.status(200).json({ success: true, data: batteries }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/api/generic-sensors/index.ts b/src/website/pages/api/generic-sensors/index.ts new file mode 100644 index 000000000..595a8ddaa --- /dev/null +++ b/src/website/pages/api/generic-sensors/index.ts @@ -0,0 +1,32 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import GenericSensors from '@/models/GenericSensors'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const genericSensors = await GenericSensors.find({}).select({ + 'genericSensors._id': 0, + _id: 0, + __v: 0, + }); + res.status(200).json({ success: true, data: genericSensors }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/api/globalpath/index.ts b/src/website/pages/api/globalpath/index.ts new file mode 100644 index 000000000..f7f88de70 --- /dev/null +++ b/src/website/pages/api/globalpath/index.ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import GlobalPath from '@/models/GlobalPath'; +import { GlobalPath as GlobalPathDocument } from '@/stores/GlobalPath/GlobalPathTypes'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const gPath: GlobalPathDocument[] = await GlobalPath.find({}).select({ + 'waypoints._id': 0, + _id: 0, + __v: 0, + }); + res.status(200).json({ success: true, data: gPath }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/api/gps/index.ts b/src/website/pages/api/gps/index.ts new file mode 100644 index 000000000..32880adeb --- /dev/null +++ b/src/website/pages/api/gps/index.ts @@ -0,0 +1,29 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import GPS from '@/models/GPS'; +import { GPS as GPSDocument } from '@/stores/GPS/GPSTypes'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const gps: GPSDocument[] = await GPS.find({}).select('-_id -__v'); + res.status(200).json({ success: true, data: gps }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/api/localpath/index.ts b/src/website/pages/api/localpath/index.ts new file mode 100644 index 000000000..fd53ceb29 --- /dev/null +++ b/src/website/pages/api/localpath/index.ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import LocalPath from '@/models/LocalPath'; +import { LocalPath as LocalPathDocument } from '@/stores/LocalPath/LocalPathTypes'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const localPath: LocalPathDocument[] = await LocalPath.find({}).select({ + 'waypoints._id': 0, + _id: 0, + __v: 0, + }); + res.status(200).json({ success: true, data: localPath }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/api/wind-sensors/index.ts b/src/website/pages/api/wind-sensors/index.ts new file mode 100644 index 000000000..5b4ee7b86 --- /dev/null +++ b/src/website/pages/api/wind-sensors/index.ts @@ -0,0 +1,32 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import ConnectMongoDB from '@/lib/mongodb'; +import WindSensors from '@/models/WindSensors'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { method } = req; + + await ConnectMongoDB(); + + switch (method) { + case 'GET': + try { + const windSensors = await WindSensors.find({}).select({ + 'windSensors._id': 0, + _id: 0, + __v: 0, + }); + res.status(200).json({ success: true, data: windSensors }); + } catch (error) { + res + .status(400) + .json({ success: false, message: (error as Error).message }); + } + break; + default: + res.status(400).json({ success: false }); + break; + } +} diff --git a/src/website/pages/dashboard/index.tsx b/src/website/pages/dashboard/index.tsx new file mode 100644 index 000000000..0299c7d84 --- /dev/null +++ b/src/website/pages/dashboard/index.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import DashboardContainer from '@/views/DashboardContainer'; + +export default function Dashboard() { + return ; +} diff --git a/src/website/pages/index.tsx b/src/website/pages/index.tsx new file mode 100644 index 000000000..8b29c9bdc --- /dev/null +++ b/src/website/pages/index.tsx @@ -0,0 +1,40 @@ +import dynamic from 'next/dynamic'; +import { CircularProgress } from '@mui/material'; +import Header from '@/views/components/Header/Header'; +import styles from './style.module.css'; + +const MapsContainer = dynamic(() => import('@/views/MapsContainer'), { + loading: () => ( + + ), + ssr: false, +}); + +const DashboardContainer = dynamic(() => import('@/views/DashboardContainer'), { + ssr: false, +}); + +export default function Home() { + return ( + <> +
+
+ +
+ +
+
+ + ); +} diff --git a/src/website/pages/style.module.css b/src/website/pages/style.module.css new file mode 100644 index 000000000..75d6e1364 --- /dev/null +++ b/src/website/pages/style.module.css @@ -0,0 +1,12 @@ +.maincontainer { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 0; + height: 50vh; + position: absolute; +} + +.dashboardcontainer { + overflow-y: scroll; + padding-left: 10px; +} diff --git a/src/website/public/BoatIconFinal.png b/src/website/public/BoatIconFinal.png new file mode 100644 index 000000000..f4f912dbf Binary files /dev/null and b/src/website/public/BoatIconFinal.png differ diff --git a/src/website/public/NSEWCompass.png b/src/website/public/NSEWCompass.png new file mode 100644 index 000000000..59a8312e3 Binary files /dev/null and b/src/website/public/NSEWCompass.png differ diff --git a/src/website/public/NSEWCompassBackdrop.png b/src/website/public/NSEWCompassBackdrop.png new file mode 100644 index 000000000..0ef342a07 Binary files /dev/null and b/src/website/public/NSEWCompassBackdrop.png differ diff --git a/src/website/public/SailbotLogo.png b/src/website/public/SailbotLogo.png new file mode 100644 index 000000000..62464612a Binary files /dev/null and b/src/website/public/SailbotLogo.png differ diff --git a/src/website/public/favicon.ico b/src/website/public/favicon.ico new file mode 100644 index 000000000..4965832f2 Binary files /dev/null and b/src/website/public/favicon.ico differ diff --git a/src/website/public/vercel.svg b/src/website/public/vercel.svg new file mode 100644 index 000000000..fbf0e25a6 --- /dev/null +++ b/src/website/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/website/stores/AISShips/AISShipsActions.ts b/src/website/stores/AISShips/AISShipsActions.ts new file mode 100644 index 000000000..df6614d9a --- /dev/null +++ b/src/website/stores/AISShips/AISShipsActions.ts @@ -0,0 +1,5 @@ +export default class AISShipsActions { + static POLL_AISSHIPS = 'POLL_AISSHIPS'; + static REQUEST_AISSHIPS_SUCCESS = 'REQUEST_AISSHIPS_SUCCESS'; + static REQUEST_AISSHIPS_FAILURE = 'REQUEST_AISSHIPS_FAILURE'; +} diff --git a/src/website/stores/AISShips/AISShipsReducers.ts b/src/website/stores/AISShips/AISShipsReducers.ts new file mode 100644 index 000000000..0d19d89b5 --- /dev/null +++ b/src/website/stores/AISShips/AISShipsReducers.ts @@ -0,0 +1,33 @@ +import BaseReducer from '@/utils/BaseReducer'; +import AISShipsActions from './AISShipsActions'; +import { AISShipsState } from './AISShipsTypes'; +import { AnyAction } from 'redux'; + +export default class AISShipsReducer extends BaseReducer { + initialState: AISShipsState = { + data: { + ships: [], + }, + error: null, + }; + + [AISShipsActions.REQUEST_AISSHIPS_SUCCESS]( + state: AISShipsState, + action: AnyAction, + ) { + return { + ...state, + data: action.payload, + }; + } + + [AISShipsActions.REQUEST_AISSHIPS_FAILURE]( + state: AISShipsState, + action: AnyAction, + ) { + return { + ...state, + error: action.error, + }; + } +} diff --git a/src/website/stores/AISShips/AISShipsSagas.ts b/src/website/stores/AISShips/AISShipsSagas.ts new file mode 100644 index 000000000..79ad9aef2 --- /dev/null +++ b/src/website/stores/AISShips/AISShipsSagas.ts @@ -0,0 +1,29 @@ +import BaseSaga from '@/utils/BaseSaga'; +import AISShipsActions from './AISShipsActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { AISShips } from './AISShipsTypes'; +import { AISShipsService } from './AISShipsService'; + +export default class AISShipsSagas extends BaseSaga { + *[AISShipsActions.POLL_AISSHIPS]() { + while (true) { + try { + const aisShips: AISShips[] = yield call(AISShipsService.getAISShips); + if (aisShips.length > 0) { + const latestAISShip = aisShips[aisShips.length - 1]; + yield put({ + type: AISShipsActions.REQUEST_AISSHIPS_SUCCESS, + payload: latestAISShip, + }); + } + } catch (e) { + yield put({ + type: AISShipsActions.REQUEST_AISSHIPS_FAILURE, + error: (e as Error).message, + }); + } + // Poll every minute. Can be adjusted accordingly. + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/AISShips/AISShipsService.ts b/src/website/stores/AISShips/AISShipsService.ts new file mode 100644 index 000000000..f1e2642cd --- /dev/null +++ b/src/website/stores/AISShips/AISShipsService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the AISShips interface. + */ +export const AISShipsService = { + *getAISShips(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/aisships`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/AISShips/AISShipsTypes.ts b/src/website/stores/AISShips/AISShipsTypes.ts new file mode 100644 index 000000000..3d01628d5 --- /dev/null +++ b/src/website/stores/AISShips/AISShipsTypes.ts @@ -0,0 +1,19 @@ +export type AISShip = { + id: number; + latitude: number; + longitude: number; + cog: number; + rot: number; + sog: number; + width: number; + length: number; +}; + +export type AISShips = { + ships: AISShip[]; +}; + +export type AISShipsState = { + data: AISShips; + error?: any; +}; diff --git a/src/website/stores/Batteries/BatteriesActions.ts b/src/website/stores/Batteries/BatteriesActions.ts new file mode 100644 index 000000000..746aee745 --- /dev/null +++ b/src/website/stores/Batteries/BatteriesActions.ts @@ -0,0 +1,5 @@ +export default class BatteriesActions { + static POLL_BATTERIES = 'POLL_BATTERIES'; + static REQUEST_BATTERIES_SUCCESS = 'REQUEST_BATTERIES_SUCCESS'; + static REQUEST_BATTERIES_FAILURE = 'REQUEST_BATTERIES_FAILURE'; +} diff --git a/src/website/stores/Batteries/BatteriesReducers.ts b/src/website/stores/Batteries/BatteriesReducers.ts new file mode 100644 index 000000000..ccbd0ed2a --- /dev/null +++ b/src/website/stores/Batteries/BatteriesReducers.ts @@ -0,0 +1,31 @@ +import BaseReducer from '@/utils/BaseReducer'; +import BatteriesActions from './BatteriesActions'; +import { BatteriesState } from './BatteriesTypes'; +import { AnyAction } from 'redux'; + +export default class BatteriesReducer extends BaseReducer { + initialState: BatteriesState = { + data: [], + error: null, + }; + + [BatteriesActions.REQUEST_BATTERIES_SUCCESS]( + state: BatteriesState, + action: AnyAction, + ) { + return { + ...state, + data: action.payload, + }; + } + + [BatteriesActions.REQUEST_BATTERIES_FAILURE]( + state: BatteriesState, + action: AnyAction, + ) { + return { + ...state, + error: action.error, + }; + } +} diff --git a/src/website/stores/Batteries/BatteriesSagas.ts b/src/website/stores/Batteries/BatteriesSagas.ts new file mode 100644 index 000000000..466f302b1 --- /dev/null +++ b/src/website/stores/Batteries/BatteriesSagas.ts @@ -0,0 +1,28 @@ +import BaseSaga from '@/utils/BaseSaga'; +import BatteriesActions from './BatteriesActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { Battery } from './BatteriesTypes'; +import { BatteriesService } from './BatteriesService'; + +export default class BatteriesSagas extends BaseSaga { + *[BatteriesActions.POLL_BATTERIES]() { + while (true) { + try { + const batteries: Battery[] = yield call(BatteriesService.getBatteries); + if (batteries.length > 0) { + yield put({ + type: BatteriesActions.REQUEST_BATTERIES_SUCCESS, + payload: batteries, + }); + } + } catch (e) { + yield put({ + type: BatteriesActions.REQUEST_BATTERIES_FAILURE, + error: (e as Error).message, + }); + } + // Poll every minute. Adjust the time as needed. + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/Batteries/BatteriesService.ts b/src/website/stores/Batteries/BatteriesService.ts new file mode 100644 index 000000000..49861f608 --- /dev/null +++ b/src/website/stores/Batteries/BatteriesService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the Batteries interface. + */ +export const BatteriesService = { + *getBatteries(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/batteries`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/Batteries/BatteriesTypes.ts b/src/website/stores/Batteries/BatteriesTypes.ts new file mode 100644 index 000000000..03b918e97 --- /dev/null +++ b/src/website/stores/Batteries/BatteriesTypes.ts @@ -0,0 +1,13 @@ +export type Battery = { + voltage: number; + current: number; +}; + +export type Batteries = { + batteries: Battery[]; +}; + +export type BatteriesState = { + data: Batteries; + error?: any; +}; diff --git a/src/website/stores/GPS/GPSActions.ts b/src/website/stores/GPS/GPSActions.ts new file mode 100644 index 000000000..34397f2bf --- /dev/null +++ b/src/website/stores/GPS/GPSActions.ts @@ -0,0 +1,5 @@ +export default class GPSActions { + static POLL_GPS = 'POLL_GPS'; + static REQUEST_GPS_SUCCESS = 'REQUEST_GPS_SUCCESS'; + static REQUEST_GPS_FAILURE = 'REQUEST_GPS_FAILURE'; +} diff --git a/src/website/stores/GPS/GPSReducers.ts b/src/website/stores/GPS/GPSReducers.ts new file mode 100644 index 000000000..3ed40c54c --- /dev/null +++ b/src/website/stores/GPS/GPSReducers.ts @@ -0,0 +1,31 @@ +import BaseReducer from '@/utils/BaseReducer'; +import GPSActions from './GPSActions'; +import { GPSState } from './GPSTypes'; +import { AnyAction } from 'redux'; + +export default class GPSReducer extends BaseReducer { + initialState: GPSState = { + data: [ + { + latitude: 49.37614179786771, + longitude: -123.27376619978901, + speed: 0, + heading: 0, + }, + ], + }; + + [GPSActions.REQUEST_GPS_SUCCESS](state: GPSState, action: AnyAction) { + return { + ...state, + data: action.payload, + }; + } + + [GPSActions.REQUEST_GPS_FAILURE](state: GPSState, action: AnyAction) { + return { + ...state, + error: action.error, + }; + } +} diff --git a/src/website/stores/GPS/GPSSagas.ts b/src/website/stores/GPS/GPSSagas.ts new file mode 100644 index 000000000..895cd2aa6 --- /dev/null +++ b/src/website/stores/GPS/GPSSagas.ts @@ -0,0 +1,24 @@ +import BaseSaga from '@/utils/BaseSaga'; +import GPSActions from './GPSActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { GPS } from './GPSTypes'; +import { GPSService } from './GPSService'; + +export default class GPSSagas extends BaseSaga { + *[GPSActions.POLL_GPS]() { + while (true) { + try { + const data: GPS[] = yield call(GPSService.getGPS); + if (data.length > 0) { + yield put({ type: GPSActions.REQUEST_GPS_SUCCESS, payload: data }); + } + } catch (e) { + yield put({ + type: GPSActions.REQUEST_GPS_FAILURE, + error: (e as Error).message, + }); + } + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/GPS/GPSService.ts b/src/website/stores/GPS/GPSService.ts new file mode 100644 index 000000000..178d01a01 --- /dev/null +++ b/src/website/stores/GPS/GPSService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the GPS interface. + */ +export const GPSService = { + *getGPS(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/gps`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/GPS/GPSTypes.ts b/src/website/stores/GPS/GPSTypes.ts new file mode 100644 index 000000000..4fca518bd --- /dev/null +++ b/src/website/stores/GPS/GPSTypes.ts @@ -0,0 +1,10 @@ +export interface GPS { + latitude: number; + longitude: number; + speed: number; + heading: number; +} +export interface GPSState { + data: GPS[]; + error?: any; +} diff --git a/src/website/stores/GenericSensors/GenericSensorsActions.ts b/src/website/stores/GenericSensors/GenericSensorsActions.ts new file mode 100644 index 000000000..a05b39a6c --- /dev/null +++ b/src/website/stores/GenericSensors/GenericSensorsActions.ts @@ -0,0 +1,5 @@ +export default class GenericSensorsActions { + static POLL_GENERICSENSORS = 'POLL_GENERICSENSORS'; + static REQUEST_GENERICSENSORS_SUCCESS = 'REQUEST_GENERICSENSORS_SUCCESS'; + static REQUEST_GENERICSENSORS_FAILURE = 'REQUEST_GENERICSENSORS_FAILURE'; +} diff --git a/src/website/stores/GenericSensors/GenericSensorsReducers.ts b/src/website/stores/GenericSensors/GenericSensorsReducers.ts new file mode 100644 index 000000000..0399c9fbe --- /dev/null +++ b/src/website/stores/GenericSensors/GenericSensorsReducers.ts @@ -0,0 +1,33 @@ +import BaseReducer from '@/utils/BaseReducer'; +import GenericSensorActions from './GenericSensorsActions'; +import { GenericSensorsState } from './GenericSensorsTypes'; +import { AnyAction } from 'redux'; + +export default class GenericSensorsReducer extends BaseReducer { + initialState: GenericSensorsState = { + data: { + genericSensors: [], + }, + error: null, + }; + + [GenericSensorActions.REQUEST_GENERICSENSORS_SUCCESS]( + state: GenericSensorsState, + action: AnyAction, + ) { + return { + ...state, + data: action.payload, + }; + } + + [GenericSensorActions.REQUEST_GENERICSENSORS_FAILURE]( + state: GenericSensorsState, + action: AnyAction, + ) { + return { + ...state, + data: action.error, + }; + } +} diff --git a/src/website/stores/GenericSensors/GenericSensorsSagas.ts b/src/website/stores/GenericSensors/GenericSensorsSagas.ts new file mode 100644 index 000000000..30746db44 --- /dev/null +++ b/src/website/stores/GenericSensors/GenericSensorsSagas.ts @@ -0,0 +1,30 @@ +import BaseSaga from '@/utils/BaseSaga'; +import GenericSensorsActions from './GenericSensorsActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { GenericSensor } from './GenericSensorsTypes'; +import { GenericSensorsService } from './GenericSensorsService'; + +export default class GenericSensorsSagas extends BaseSaga { + *[GenericSensorsActions.POLL_GENERICSENSORS]() { + while (true) { + try { + const genericSensors: GenericSensor[] = yield call( + GenericSensorsService.getGenericSensors, + ); + if (genericSensors.length > 0) { + yield put({ + type: GenericSensorsActions.REQUEST_GENERICSENSORS_SUCCESS, + payload: genericSensors, + }); + } + } catch (e) { + yield put({ + type: GenericSensorsActions.REQUEST_GENERICSENSORS_FAILURE, + error: (e as Error).message, + }); + } + // Poll every minute. Adjust the time as needed. + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/GenericSensors/GenericSensorsService.ts b/src/website/stores/GenericSensors/GenericSensorsService.ts new file mode 100644 index 000000000..37b91533c --- /dev/null +++ b/src/website/stores/GenericSensors/GenericSensorsService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the GenericSensors interface. + */ +export const GenericSensorsService = { + *getGenericSensors(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/generic-sensors`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/GenericSensors/GenericSensorsTypes.ts b/src/website/stores/GenericSensors/GenericSensorsTypes.ts new file mode 100644 index 000000000..ccda11b4f --- /dev/null +++ b/src/website/stores/GenericSensors/GenericSensorsTypes.ts @@ -0,0 +1,13 @@ +export type GenericSensor = { + id: number; + data: bigint; +}; + +export type GenericSensors = { + genericSensors: GenericSensor[]; +}; + +export type GenericSensorsState = { + data: GenericSensors; + error?: any; +}; diff --git a/src/website/stores/GlobalPath/GlobalPathActions.ts b/src/website/stores/GlobalPath/GlobalPathActions.ts new file mode 100644 index 000000000..8c4085c57 --- /dev/null +++ b/src/website/stores/GlobalPath/GlobalPathActions.ts @@ -0,0 +1,5 @@ +export default class GlobalPathActions { + static POLL_GLOBALPATH = 'POLL_GLOBALPATH'; + static REQUEST_GLOBALPATH_SUCCESS = 'REQUEST_GLOBALPATH_SUCCESS'; + static REQUEST_GLOBALPATH_FAILURE = 'REQUEST_GLOBALPATH_FAILURE'; +} diff --git a/src/website/stores/GlobalPath/GlobalPathReducers.ts b/src/website/stores/GlobalPath/GlobalPathReducers.ts new file mode 100644 index 000000000..1ba9cb7cf --- /dev/null +++ b/src/website/stores/GlobalPath/GlobalPathReducers.ts @@ -0,0 +1,33 @@ +import BaseReducer from '@/utils/BaseReducer'; +import GlobalPathActions from './GlobalPathActions'; +import { GlobalPathState } from './GlobalPathTypes'; +import { AnyAction } from 'redux'; + +export default class GlobalPathReducer extends BaseReducer { + initialState: GlobalPathState = { + data: { + waypoints: [], + }, + error: null, + }; + + [GlobalPathActions.REQUEST_GLOBALPATH_SUCCESS]( + state: GlobalPathState, + action: AnyAction, + ) { + return { + ...state, + data: action.payload, + }; + } + + [GlobalPathActions.REQUEST_GLOBALPATH_FAILURE]( + state: GlobalPathState, + action: AnyAction, + ) { + return { + ...state, + error: action.error, + }; + } +} diff --git a/src/website/stores/GlobalPath/GlobalPathSagas.ts b/src/website/stores/GlobalPath/GlobalPathSagas.ts new file mode 100644 index 000000000..77462cc53 --- /dev/null +++ b/src/website/stores/GlobalPath/GlobalPathSagas.ts @@ -0,0 +1,30 @@ +import BaseSaga from '@/utils/BaseSaga'; +import GlobalPathActions from './GlobalPathActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { GlobalPath } from './GlobalPathTypes'; +import { GlobalPathService } from './GlobalPathService'; + +export default class GlobalPathSagas extends BaseSaga { + *[GlobalPathActions.POLL_GLOBALPATH]() { + while (true) { + try { + const globalPath: GlobalPath[] = yield call( + GlobalPathService.getGlobalPath, + ); + if (globalPath.length > 0) { + yield put({ + type: GlobalPathActions.REQUEST_GLOBALPATH_SUCCESS, + payload: globalPath[globalPath.length - 1], + }); + } + } catch (e) { + yield put({ + type: GlobalPathActions.REQUEST_GLOBALPATH_FAILURE, + error: (e as Error).message, + }); + } + // poll every minute - can be adjusted accordingly + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/GlobalPath/GlobalPathService.ts b/src/website/stores/GlobalPath/GlobalPathService.ts new file mode 100644 index 000000000..cfdea4424 --- /dev/null +++ b/src/website/stores/GlobalPath/GlobalPathService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the GlobalPath interface. + */ +export const GlobalPathService = { + *getGlobalPath(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/globalpath`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/GlobalPath/GlobalPathTypes.ts b/src/website/stores/GlobalPath/GlobalPathTypes.ts new file mode 100644 index 000000000..46fb2fa6a --- /dev/null +++ b/src/website/stores/GlobalPath/GlobalPathTypes.ts @@ -0,0 +1,13 @@ +export type WayPoint = { + latitude: number; + longitude: number; +}; + +export type GlobalPath = { + waypoints: WayPoint[]; +}; + +export type GlobalPathState = { + data: GlobalPath; + error?: any; +}; diff --git a/src/website/stores/LocalPath/LocalPathActions.ts b/src/website/stores/LocalPath/LocalPathActions.ts new file mode 100644 index 000000000..3a314f57d --- /dev/null +++ b/src/website/stores/LocalPath/LocalPathActions.ts @@ -0,0 +1,5 @@ +export default class LocalPathActions { + static POLL_LOCALPATH = 'POLL_LOCALPATH'; + static REQUEST_LOCALPATH_SUCCESS = 'REQUEST_LOCALPATH_SUCCESS'; + static REQUEST_LOCALPATH_FAILURE = 'REQUEST_LOCALPATH_FAILURE'; +} diff --git a/src/website/stores/LocalPath/LocalPathReducers.ts b/src/website/stores/LocalPath/LocalPathReducers.ts new file mode 100644 index 000000000..37c34e70a --- /dev/null +++ b/src/website/stores/LocalPath/LocalPathReducers.ts @@ -0,0 +1,33 @@ +import BaseReducer from '@/utils/BaseReducer'; +import LocalPathActions from './LocalPathActions'; +import { LocalPathState } from './LocalPathTypes'; +import { AnyAction } from 'redux'; + +export default class LocalPathReducer extends BaseReducer { + initialState: LocalPathState = { + data: { + waypoints: [], + }, + error: null, + }; + + [LocalPathActions.REQUEST_LOCALPATH_SUCCESS]( + state: LocalPathState, + action: AnyAction, + ) { + return { + ...state, + data: action.payload, + }; + } + + [LocalPathActions.REQUEST_LOCALPATH_FAILURE]( + state: LocalPathState, + action: AnyAction, + ) { + return { + ...state, + error: action.error, + }; + } +} diff --git a/src/website/stores/LocalPath/LocalPathSagas.ts b/src/website/stores/LocalPath/LocalPathSagas.ts new file mode 100644 index 000000000..3ca134d57 --- /dev/null +++ b/src/website/stores/LocalPath/LocalPathSagas.ts @@ -0,0 +1,30 @@ +import BaseSaga from '@/utils/BaseSaga'; +import LocalPathActions from './LocalPathActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { LocalPath } from './LocalPathTypes'; +import { LocalPathService } from './LocalPathService'; + +export default class LocalPathSagas extends BaseSaga { + *[LocalPathActions.POLL_LOCALPATH]() { + while (true) { + try { + const localPath: LocalPath[] = yield call( + LocalPathService.getLocalPath, + ); + if (localPath.length > 0) { + yield put({ + type: LocalPathActions.REQUEST_LOCALPATH_SUCCESS, + payload: localPath[localPath.length - 1], + }); + } + } catch (e) { + yield put({ + type: LocalPathActions.REQUEST_LOCALPATH_FAILURE, + error: (e as Error).message, + }); + } + // poll every minute - can be adjusted accordingly + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/LocalPath/LocalPathService.ts b/src/website/stores/LocalPath/LocalPathService.ts new file mode 100644 index 000000000..dbcb6b29e --- /dev/null +++ b/src/website/stores/LocalPath/LocalPathService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the LocalPath interface. + */ +export const LocalPathService = { + *getLocalPath(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/localpath`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/LocalPath/LocalPathTypes.ts b/src/website/stores/LocalPath/LocalPathTypes.ts new file mode 100644 index 000000000..1bec6852a --- /dev/null +++ b/src/website/stores/LocalPath/LocalPathTypes.ts @@ -0,0 +1,13 @@ +export type WayPoint = { + latitude: number; + longitude: number; +}; + +export type LocalPath = { + waypoints: WayPoint[]; +}; + +export type LocalPathState = { + data: LocalPath; + error?: any; +}; diff --git a/src/website/stores/WindSensors/WindSensorsActions.ts b/src/website/stores/WindSensors/WindSensorsActions.ts new file mode 100644 index 000000000..f9992ba18 --- /dev/null +++ b/src/website/stores/WindSensors/WindSensorsActions.ts @@ -0,0 +1,5 @@ +export default class WindSensorsAction { + static POLL_WINDSENSORS = 'POLL_WINDSENSORS'; + static REQUEST_WINDSENSORS_SUCCESS = 'REQUEST_WINDSENSORS_SUCCESS'; + static REQUEST_WINDSENSORS_FAILURE = 'REQUEST_WINDSENSORS_FAILURE'; +} diff --git a/src/website/stores/WindSensors/WindSensorsReducers.ts b/src/website/stores/WindSensors/WindSensorsReducers.ts new file mode 100644 index 000000000..ad0e43489 --- /dev/null +++ b/src/website/stores/WindSensors/WindSensorsReducers.ts @@ -0,0 +1,31 @@ +import BaseReducer from '@/utils/BaseReducer'; +import WindSensorsAction from './WindSensorsActions'; +import { WindSensorsState } from './WindSensorsTypes'; +import { AnyAction } from 'redux'; + +export default class WindSensorsReducer extends BaseReducer { + initialState: WindSensorsState = { + data: [], + error: null, + }; + + [WindSensorsAction.REQUEST_WINDSENSORS_SUCCESS]( + state: WindSensorsState, + action: AnyAction, + ) { + return { + ...state, + data: action.payload, + }; + } + + [WindSensorsAction.REQUEST_WINDSENSORS_FAILURE]( + state: WindSensorsState, + action: AnyAction, + ) { + return { + ...state, + data: action.error, + }; + } +} diff --git a/src/website/stores/WindSensors/WindSensorsSagas.ts b/src/website/stores/WindSensors/WindSensorsSagas.ts new file mode 100644 index 000000000..95611a12a --- /dev/null +++ b/src/website/stores/WindSensors/WindSensorsSagas.ts @@ -0,0 +1,30 @@ +import BaseSaga from '@/utils/BaseSaga'; +import WindSensorsAction from './WindSensorsActions'; +import { call, delay, put } from 'redux-saga/effects'; +import { WindSensor } from './WindSensorsTypes'; +import { WindSensorsService } from './WindSensorsService'; + +export default class WindSensorsSagas extends BaseSaga { + *[WindSensorsAction.POLL_WINDSENSORS]() { + while (true) { + try { + const windSensors: WindSensor[] = yield call( + WindSensorsService.getWindSensors, + ); + if (windSensors.length > 0) { + yield put({ + type: WindSensorsAction.REQUEST_WINDSENSORS_SUCCESS, + payload: windSensors, + }); + } + } catch (e) { + yield put({ + type: WindSensorsAction.REQUEST_WINDSENSORS_FAILURE, + error: (e as Error).message, + }); + } + // Poll every minute. Adjust the time as needed. + yield delay(process.env.NEXT_PUBLIC_POLLING_TIME_MS); + } + } +} diff --git a/src/website/stores/WindSensors/WindSensorsService.ts b/src/website/stores/WindSensors/WindSensorsService.ts new file mode 100644 index 000000000..99210fd2e --- /dev/null +++ b/src/website/stores/WindSensors/WindSensorsService.ts @@ -0,0 +1,26 @@ +/** + * Defines all saga methods to make requests to the WindSensors interface. + */ +export const WindSensorsService = { + *getWindSensors(): Generator { + let isError = false; + + return yield fetch( + `${process.env.NEXT_PUBLIC_SERVER_HOST}:${process.env.NEXT_PUBLIC_SERVER_PORT}/api/wind-sensors`, + { + method: 'GET', + }, + ) + .then((response) => { + isError = response.status != 200; + return response; + }) + .then((response) => response.json()) + .then((json) => { + if (isError) { + throw new Error(json.error); + } + return json.data; + }); + }, +}; diff --git a/src/website/stores/WindSensors/WindSensorsTypes.ts b/src/website/stores/WindSensors/WindSensorsTypes.ts new file mode 100644 index 000000000..524fed9d7 --- /dev/null +++ b/src/website/stores/WindSensors/WindSensorsTypes.ts @@ -0,0 +1,13 @@ +export type WindSensor = { + speed: number; + direction: number; +}; + +export type WindSensors = { + windSensors: WindSensor[]; +}; + +export type WindSensorsState = { + data: WindSensors; + error?: any; +}; diff --git a/src/website/tests/README.md b/src/website/tests/README.md new file mode 100644 index 000000000..0f54c2f73 --- /dev/null +++ b/src/website/tests/README.md @@ -0,0 +1,24 @@ +# Tests + +The tests for the website use [Cucumber.js](https://github.com/cucumber/cucumber-js), with +[Typescript](https://www.typescriptlang.org/). + +## Prerequisites + +- website is running as written in the README.md +- test config is set appropriately `.../tests/shared/config.ts` +- execute `npm install` from this directory to install dependencies. + +## Scripts + +| Command | Description | +| ---------------------------- | ---------------------- | +| npm test | Runs all tests | +| npm run test-tag @{tag-name} | Runs tests for TAGNAME | + +### Writing Tests Help/Tips + +- Common test steps -> `./steps/common.ts` +- All the CRUD API requests -> `./shared/classes/api.ts` +- If you expect requests to fail ensure `failOnError` is set to false on API request +- world object `./world/world.ts` defines the global `this` reference inside your steps. diff --git a/src/website/tests/cucumber.js b/src/website/tests/cucumber.js new file mode 100644 index 000000000..b05291494 --- /dev/null +++ b/src/website/tests/cucumber.js @@ -0,0 +1,9 @@ +module.exports = { + default: [ + '--require-module ts-node/register', + '--require features/**/*.ts', + '--require steps/**/*.ts', // Load step definitions + '--require shared/**/*.ts', // Shared utility functions + '--require world/**/*.ts', // Shared utility functions + ].join(' '), +}; diff --git a/src/website/tests/features/aisships.feature b/src/website/tests/features/aisships.feature new file mode 100644 index 000000000..64fc22276 --- /dev/null +++ b/src/website/tests/features/aisships.feature @@ -0,0 +1,9 @@ +@aisships +Feature: Testing AISShips interface + + Scenario: Fetch AISShips data from the API + Given I clear the database + And I insert AISShips data into the database + When I get all AISShip interface data + Then the service success response is 200 + And the response data matches the aisship data in the database diff --git a/src/website/tests/features/batteries.feature b/src/website/tests/features/batteries.feature new file mode 100644 index 000000000..4e5a5d2a8 --- /dev/null +++ b/src/website/tests/features/batteries.feature @@ -0,0 +1,9 @@ +@batteries +Feature: Testing the Batteries API + + Scenario: Fetch Batteries data from the API + Given I clear the database + And I insert Batteries data into the database + When I get all Batteries interface data + Then the service success response is 200 + And the response data matches the Batteries data in the database diff --git a/src/website/tests/features/genericsensors.feature b/src/website/tests/features/genericsensors.feature new file mode 100644 index 000000000..4023f8433 --- /dev/null +++ b/src/website/tests/features/genericsensors.feature @@ -0,0 +1,9 @@ +@generic-sensors +Feature: Testing the GenericSensors API + + Scenario: Fetch GenericSensors data from the API + Given I clear the database + And I insert GenericSensors data into the database + When I get all GenericSensors interface data + Then the service success response is 200 + And the response data matches the GenericSensors data in the database diff --git a/src/website/tests/features/globalpath.feature b/src/website/tests/features/globalpath.feature new file mode 100644 index 000000000..a95a52de0 --- /dev/null +++ b/src/website/tests/features/globalpath.feature @@ -0,0 +1,9 @@ +@globalpath +Feature: Testing the GlobalPath API + + Scenario: Fetch GlobalPath data from the API + Given I clear the database + And I insert GlobalPath data into the database + When I get all GlobalPath interface data + Then the service success response is 200 + And the response data matches the GlobalPath data in the database diff --git a/src/website/tests/features/gps.feature b/src/website/tests/features/gps.feature new file mode 100644 index 000000000..ddfa6f434 --- /dev/null +++ b/src/website/tests/features/gps.feature @@ -0,0 +1,9 @@ +@gps +Feature: Testing the GPS API + + Scenario: Fetch GPS data from the API + Given I clear the database + And I insert GPS data into the database + When I get all GPS interface data + Then the service success response is 200 + And the response data matches the data in the database diff --git a/src/website/tests/features/localpath.feature b/src/website/tests/features/localpath.feature new file mode 100644 index 000000000..33990129b --- /dev/null +++ b/src/website/tests/features/localpath.feature @@ -0,0 +1,9 @@ +@localpath +Feature: Testing the LocalPath API + + Scenario: Fetch LocalPath data from the API + Given I clear the database + And I insert LocalPath data into the database + When I get all LocalPath interface data + Then the service success response is 200 + And the response data matches the LocalPath data in the database diff --git a/src/website/tests/features/windsensors.feature b/src/website/tests/features/windsensors.feature new file mode 100644 index 000000000..1ec6850b9 --- /dev/null +++ b/src/website/tests/features/windsensors.feature @@ -0,0 +1,9 @@ +@wind-sensors +Feature: Testing the WindSensors API + + Scenario: Fetch WindSensors data from the API + Given I clear the database + And I insert WindSensors data into the database + When I get all WindSensors interface data + Then the service success response is 200 + And the response data matches the WindSensors data in the database diff --git a/src/website/tests/package-lock.json b/src/website/tests/package-lock.json new file mode 100644 index 000000000..194755a28 --- /dev/null +++ b/src/website/tests/package-lock.json @@ -0,0 +1,2062 @@ +{ + "name": "website-component-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "website-component-tests", + "version": "1.0.0", + "dependencies": { + "@cucumber/cucumber": "8.11.1", + "@cucumber/messages": "20.0.0", + "@cucumber/pretty-formatter": "1.0.0", + "amqp-ts": "1.8.0", + "axios": "0.27.2", + "chai": "4.3.7", + "deep-equal-in-any-order": "1.1.20", + "mongoose": "^7.5.3", + "ts-node": "10.9.1", + "typescript": "4.9.5", + "winston": "^3.10.0" + }, + "devDependencies": { + "@cucumber/cucumber": "*", + "@types/chai": "^4.3.6", + "ts-node": "^10.4.0", + "tsconfig-paths": "^4.2.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cucumber/ci-environment": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-9.2.0.tgz", + "integrity": "sha512-jLzRtVwdtNt+uAmTwvXwW9iGYLEOJFpDSmnx/dgoMGKXUWRx1UHT86Q696CLdgXO8kyTwsgJY0c6n5SW9VitAA==" + }, + "node_modules/@cucumber/cucumber": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-9.5.1.tgz", + "integrity": "sha512-9fRRxbRRkXxRB4D7C21ZjmEvBWI3Su7yNQDvRPfgHnd+xVKaOTvfPs/lZWN1TOiwYlnW5nupRaaCx5xV+fzurw==", + "dependencies": { + "@cucumber/ci-environment": "9.2.0", + "@cucumber/cucumber-expressions": "16.1.2", + "@cucumber/gherkin": "26.2.0", + "@cucumber/gherkin-streams": "5.0.1", + "@cucumber/gherkin-utils": "8.0.2", + "@cucumber/html-formatter": "20.4.0", + "@cucumber/message-streams": "4.0.1", + "@cucumber/messages": "22.0.0", + "@cucumber/tag-expressions": "5.0.1", + "assertion-error-formatter": "^3.0.0", + "capital-case": "^1.0.4", + "chalk": "^4.1.2", + "cli-table3": "0.6.3", + "commander": "^10.0.0", + "debug": "^4.3.4", + "error-stack-parser": "^2.1.4", + "figures": "^3.2.0", + "glob": "^7.1.6", + "has-ansi": "^4.0.1", + "indent-string": "^4.0.0", + "is-installed-globally": "^0.4.0", + "is-stream": "^2.0.0", + "knuth-shuffle-seeded": "^1.0.6", + "lodash.merge": "^4.6.2", + "lodash.mergewith": "^4.6.2", + "luxon": "3.2.1", + "mkdirp": "^2.1.5", + "mz": "^2.7.0", + "progress": "^2.0.3", + "resolve-pkg": "^2.0.0", + "semver": "7.5.3", + "string-argv": "^0.3.1", + "strip-ansi": "6.0.1", + "supports-color": "^8.1.1", + "tmp": "^0.2.1", + "util-arity": "^1.1.0", + "verror": "^1.10.0", + "xmlbuilder": "^15.1.1", + "yaml": "^2.2.2", + "yup": "1.2.0" + }, + "bin": { + "cucumber-js": "bin/cucumber.js" + }, + "engines": { + "node": "14 || 16 || >=18" + } + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-16.1.2.tgz", + "integrity": "sha512-CfHEbxJ5FqBwF6mJyLLz4B353gyHkoi6cCL4J0lfDZ+GorpcWw4n2OUAdxJmP7ZlREANWoTFlp4FhmkLKrCfUA==", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/cucumber/node_modules/@cucumber/messages": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-22.0.0.tgz", + "integrity": "sha512-EuaUtYte9ilkxcKmfqGF9pJsHRUU0jwie5ukuZ/1NPTuHS1LxHPsGEODK17RPRbZHOFhqybNzG2rHAwThxEymg==", + "dependencies": { + "@types/uuid": "9.0.1", + "class-transformer": "0.5.1", + "reflect-metadata": "0.1.13", + "uuid": "9.0.0" + } + }, + "node_modules/@cucumber/cucumber/node_modules/@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==" + }, + "node_modules/@cucumber/gherkin": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-26.2.0.tgz", + "integrity": "sha512-iRSiK8YAIHAmLrn/mUfpAx7OXZ7LyNlh1zT89RoziSVCbqSVDxJS6ckEzW8loxs+EEXl0dKPQOXiDmbHV+C/fA==", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=22" + } + }, + "node_modules/@cucumber/gherkin-streams": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-5.0.1.tgz", + "integrity": "sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==", + "dependencies": { + "commander": "9.1.0", + "source-map-support": "0.5.21" + }, + "bin": { + "gherkin-javascript": "bin/gherkin" + }, + "peerDependencies": { + "@cucumber/gherkin": ">=22.0.0", + "@cucumber/message-streams": ">=4.0.0", + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/gherkin-streams/node_modules/commander": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.1.0.tgz", + "integrity": "sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-8.0.2.tgz", + "integrity": "sha512-aQlziN3r3cTwprEDbLEcFoMRQajb9DTOu2OZZp5xkuNz6bjSTowSY90lHUD2pWT7jhEEckZRIREnk7MAwC2d1A==", + "dependencies": { + "@cucumber/gherkin": "^25.0.0", + "@cucumber/messages": "^19.1.4", + "@teppeis/multimaps": "2.0.0", + "commander": "9.4.1", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-25.0.2.tgz", + "integrity": "sha512-EdsrR33Y5GjuOoe2Kq5Y9DYwgNRtUD32H4y2hCrT6+AWo7ibUQu7H+oiWTgfVhwbkHsZmksxHSxXz/AwqqyCRQ==", + "dependencies": { + "@cucumber/messages": "^19.1.4" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/messages": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-19.1.4.tgz", + "integrity": "sha512-Pksl0pnDz2l1+L5Ug85NlG6LWrrklN9qkMxN5Mv+1XZ3T6u580dnE6mVaxjJRdcOq4tR17Pc0RqIDZMyVY1FlA==", + "dependencies": { + "@types/uuid": "8.3.4", + "class-transformer": "0.5.1", + "reflect-metadata": "0.1.13", + "uuid": "9.0.0" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", + "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-20.4.0.tgz", + "integrity": "sha512-TnLSXC5eJd8AXHENo69f5z+SixEVtQIf7Q2dZuTpT/Y8AOkilGpGl1MQR1Vp59JIw+fF3EQSUKdf+DAThCxUNg==", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/message-streams": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", + "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", + "peerDependencies": { + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/messages": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-20.0.0.tgz", + "integrity": "sha512-JFrFwuhxsbig0afaViNhuzoQyC+GQzlI7m+rX+lSiDGV13K3sJzMmHjkbCiNOgoRlKAMwIGR9TRMH0xj9/My0w==", + "dependencies": { + "@types/uuid": "8.3.4", + "class-transformer": "0.5.1", + "reflect-metadata": "0.1.13", + "uuid": "9.0.0" + } + }, + "node_modules/@cucumber/pretty-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/pretty-formatter/-/pretty-formatter-1.0.0.tgz", + "integrity": "sha512-wcnIMN94HyaHGsfq72dgCvr1d8q6VGH4Y6Gl5weJ2TNZw1qn2UY85Iki4c9VdaLUONYnyYH3+178YB+9RFe/Hw==", + "dependencies": { + "ansi-styles": "^5.0.0", + "cli-table3": "^0.6.0", + "figures": "^3.2.0", + "ts-dedent": "^2.0.0" + }, + "peerDependencies": { + "@cucumber/cucumber": ">=7.0.0", + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-5.0.1.tgz", + "integrity": "sha512-N43uWud8ZXuVjza423T9ZCIJsaZhFekmakt7S9bvogTxqdVGbRobjR663s0+uW0Rz9e+Pa8I6jUuWtoBLQD2Mw==" + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@teppeis/multimaps": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-2.0.0.tgz", + "integrity": "sha512-TL1adzq1HdxUf9WYduLcQ/DNGYiz71U31QRgbnr0Ef1cPyOUOsBojxHVWpFeOSUucB6Lrs0LxFRA14ntgtkc9w==", + "engines": { + "node": ">=10.17" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/chai": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz", + "integrity": "sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.7.1.tgz", + "integrity": "sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg==" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.3.tgz", + "integrity": "sha512-6tOUG+nVHn0cJbVp25JFayS5UE6+xlbcNF9Lo9mU7U0zk3zeUShZied4YEQZjy1JBF043FSkdXw8YkUJuVtB5g==" + }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.1.tgz", + "integrity": "sha512-8hKOnOan+Uu+NgMaCouhg3cT9x5fFZ92Jwf+uDLXLu/MFRbXxlWwGeQY7KVHkeSft6RvY+tdxklUBuyY9eIEKg==" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, + "node_modules/acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/amqp-ts": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/amqp-ts/-/amqp-ts-1.8.0.tgz", + "integrity": "sha512-XIqxqfpJX4G3KkGcpyPhVXXmw8S+BZfzQuSPB504RBibi55XEpHEHgFNWWNHRax09bmiPgPYQmTNEec6pJUfMQ==", + "dependencies": { + "@types/node": "^12.7.2", + "amqplib": "^0.4.1", + "bluebird": "^3.3.5", + "winston": "^2.2.0" + } + }, + "node_modules/amqp-ts/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" + }, + "node_modules/amqp-ts/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/amqp-ts/node_modules/winston": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz", + "integrity": "sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==", + "dependencies": { + "async": "^2.6.4", + "colors": "1.0.x", + "cycle": "1.0.x", + "eyes": "0.1.x", + "isstream": "0.1.x", + "stack-trace": "0.0.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/amqplib": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.4.2.tgz", + "integrity": "sha512-B8iSyozIqKcR72wFeS+VrCYmzqyCIRKDnDr8nnz2FBsnpKjia4pqby2/r4FluhyaIzBqKGaldYWy95g0OKPHUA==", + "dependencies": { + "bitsyntax": "~0.0.4", + "buffer-more-ints": "0.0.2", + "readable-stream": "1.x >=1.1.9", + "when": "~3.6.2" + }, + "engines": { + "node": ">=0.8 <6 || ^6" + } + }, + "node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "engines": { + "node": "*" + } + }, + "node_modules/assertion-error-formatter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/assertion-error-formatter/-/assertion-error-formatter-3.0.0.tgz", + "integrity": "sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==", + "dependencies": { + "diff": "^4.0.1", + "pad-right": "^0.2.2", + "repeat-string": "^1.6.1" + } + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bitsyntax": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/bitsyntax/-/bitsyntax-0.0.4.tgz", + "integrity": "sha512-Pav3HSZXD2NLQOWfJldY3bpJLt8+HS2nUo5Z1bLLmHg2vCE/cM1qfEvNjlYo7GgYQPneNr715Bh42i01ZHZPvw==", + "dependencies": { + "buffer-more-ints": "0.0.2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bson": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.0.tgz", + "integrity": "sha512-B+QB4YmDx9RStKv8LLSl/aVIEV3nYJc3cJNNTK2Cd1TL+7P+cNpw9mAPeCgc5K+j01Dv6sxUzcITXDx7ZU3F0w==", + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/buffer-more-ints": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-0.0.2.tgz", + "integrity": "sha512-PDgX2QJgUc5+Jb2xAoBFP5MxhtVUmZHR33ak+m/SDxRdCrbnX1BggRIaxiW7ImwfmO4iJeCQKN18ToSXWGjYkA==" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", + "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^4.1.2", + "get-func-name": "^2.0.0", + "loupe": "^2.3.1", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/chalk/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/cli-table3": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", + "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal-in-any-order": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-1.1.20.tgz", + "integrity": "sha512-GTpQxcQx28KvV6ChrHb4AcL5z+eFEymtoSUaWe+FEakEpZ/66jTvz52xLUym/sK4Pn1sbYLsCV5rEURDeiFl3w==", + "dependencies": { + "lodash.mapvalues": "^4.6.0", + "sort-any": "^2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "engines": { + "node": "> 0.1.90" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "engines": { + "node": "*" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-ansi": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz", + "integrity": "sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/knuth-shuffle-seeded": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz", + "integrity": "sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==", + "dependencies": { + "seed-random": "~2.2.0" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.mapvalues": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", + "integrity": "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, + "node_modules/logform": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.5.1.tgz", + "integrity": "sha512-9FyqAm9o9NKKfiAKfZoYo9bGXXuwMkxQiQttkT4YjjVtQVIQtK6LmVtlxmCaFswo6N4AfEkHqZTV0taDtPotNg==", + "dependencies": { + "@colors/colors": "1.5.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + } + }, + "node_modules/loupe": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", + "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "dependencies": { + "get-func-name": "^2.0.0" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/luxon": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz", + "integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "optional": true + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", + "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mongodb": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.8.1.tgz", + "integrity": "sha512-wKyh4kZvm6NrCPH8AxyzXm3JBoEf4Xulo0aUWh3hCgwgYJxyQ1KLST86ZZaSWdj6/kxYUA3+YZuyADCE61CMSg==", + "dependencies": { + "bson": "^5.4.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongoose": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.5.3.tgz", + "integrity": "sha512-QyYzhZusux0wIJs+4rYyHvel0kJm0CT887trNd1WAB3iQnDuJow0xEnjETvuS/cTjHQUVPihOpN7OHLlpJc52w==", + "dependencies": { + "bson": "^5.4.0", + "kareem": "2.5.1", + "mongodb": "5.8.1", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/pad-right": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/pad-right/-/pad-right-0.2.2.tgz", + "integrity": "sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==", + "dependencies": { + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "engines": { + "node": "*" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg/-/resolve-pkg-2.0.0.tgz", + "integrity": "sha512-+1lzwXehGCXSeryaISr6WujZzowloigEofRB+dj75y9RRa/obVcYgbHJd53tdYw8pvZj8GojXaaENws8Ktw/hQ==", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, + "node_modules/seed-random": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", + "integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==" + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sort-any": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/sort-any/-/sort-any-2.0.0.tgz", + "integrity": "sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA==", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "optional": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/util-arity": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz", + "integrity": "sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/when": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/when/-/when-3.6.4.tgz", + "integrity": "sha512-d1VUP9F96w664lKINMGeElWdhhb5sC+thXM+ydZGU3ZnaE09Wv6FaS+mpM9570kcDs/xMfcXJBTLsMdHEFYY9Q==" + }, + "node_modules/winston": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.10.0.tgz", + "integrity": "sha512-nT6SIDaE9B7ZRO0u3UvdrimG0HkB7dSTAgInQnNR2SOPJ4bvq5q79+pXLftKmP52lJGW15+H5MCK0nM9D3KB/g==", + "dependencies": { + "@colors/colors": "1.5.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.4.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz", + "integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==", + "dependencies": { + "logform": "^2.3.2", + "readable-stream": "^3.6.0", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 6.4.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston-transport/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yup": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.2.0.tgz", + "integrity": "sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + } + } +} diff --git a/src/website/tests/package.json b/src/website/tests/package.json new file mode 100644 index 000000000..cd8d42913 --- /dev/null +++ b/src/website/tests/package.json @@ -0,0 +1,28 @@ +{ + "name": "website-component-tests", + "version": "1.0.0", + "description": "Cucumber component tests for Sailbot Website", + "scripts": { + "test": "cucumber-js --exit", + "test-tag": "./node_modules/.bin/cucumber-js --exit -p default --tags " + }, + "dependencies": { + "@cucumber/cucumber": "8.11.1", + "@cucumber/messages": "20.0.0", + "@cucumber/pretty-formatter": "1.0.0", + "amqp-ts": "1.8.0", + "axios": "0.27.2", + "chai": "4.3.7", + "deep-equal-in-any-order": "1.1.20", + "mongoose": "^7.5.3", + "ts-node": "10.9.1", + "typescript": "4.9.5", + "winston": "^3.10.0" + }, + "devDependencies": { + "@cucumber/cucumber": "*", + "@types/chai": "^4.3.6", + "ts-node": "^10.4.0", + "tsconfig-paths": "^4.2.0" + } +} diff --git a/src/website/tests/shared/classes/api.ts b/src/website/tests/shared/classes/api.ts new file mode 100644 index 000000000..54750184e --- /dev/null +++ b/src/website/tests/shared/classes/api.ts @@ -0,0 +1,121 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { assert } from 'chai'; +import { SERVICE_URL } from '../config'; +import { logger } from '../utils'; + +export class Api { + public response: any; + + public error: any; + + private axiosInstance = axios.create({ + baseURL: SERVICE_URL, + }); + + get = async ( + endpoint: string, + config: AxiosRequestConfig, + failOnError = true, + ): Promise => { + this.logRequest({}, config); + try { + this.response = await this.axiosInstance.get(endpoint, config); + this.logResponse(); + return this.response.data; + } catch (error) { + this.logError(error, failOnError); + return null; + } + }; + + post = async ( + endpoint: string, + requestObject: object, + config: AxiosRequestConfig, + failOnError = true, + ): Promise => { + this.logRequest(requestObject, config); + try { + this.response = await this.axiosInstance.post( + endpoint, + requestObject, + config, + ); + this.logResponse(); + return this.response.data; + } catch (error) { + this.logError(error, failOnError); + return null; + } + }; + + put = async ( + endpoint: string, + requestObject: object, + config: AxiosRequestConfig, + failOnError = true, + ): Promise => { + this.logRequest(requestObject, config); + try { + this.response = await this.axiosInstance.put( + endpoint, + requestObject, + config, + ); + this.logResponse(); + return this.response.data; + } catch (error) { + this.logError(error, failOnError); + return null; + } + }; + + delete = async ( + endpoint: string, + config: AxiosRequestConfig, + failOnError = true, + ): Promise => { + this.logRequest({}, config); + try { + this.response = await this.axiosInstance.delete(endpoint, config); + this.logResponse(); + return this.response; + } catch (error) { + this.logError(error, failOnError); + return null; + } + }; + + get responseData(): object { + return this.response.data; + } + + logResponse = (): void => { + logger.info(this.response.config); + logger.info(`status code: ${this.response.status}`); + logger.info(this.response.data); + }; + + /* eslint-disable class-methods-use-this */ + logRequest = (request, config): void => { + logger.info(request); + logger.info(config); + }; + + logError = (error: any, failOnError = true): void => { + this.error = error; + const errorObjStr = JSON.stringify(error); + const errorData = error.response.data; + + if (failOnError) { + logger.error(errorObjStr); + logger.error(errorData); + assert.fail(JSON.stringify(errorData)); + } else { + logger.info(errorObjStr); + logger.info(errorData); + } + }; +} + +export const api = new Api(); diff --git a/src/website/tests/shared/classes/response-error.ts b/src/website/tests/shared/classes/response-error.ts new file mode 100644 index 000000000..f4df9df3f --- /dev/null +++ b/src/website/tests/shared/classes/response-error.ts @@ -0,0 +1,20 @@ +import { ResponseErrorObject } from '../table-objects/table-objects'; + +export default class ResponseError { + public detail: string; + + public title: string; + + public status: string; + + /** + * Error response must include all the following properties. + * + * @param errObj ResponseErrorObject + */ + constructor(errObj: ResponseErrorObject) { + this.detail = errObj.detail; + this.title = errObj.title; + this.status = errObj.status; + } +} diff --git a/src/website/tests/shared/config.ts b/src/website/tests/shared/config.ts new file mode 100644 index 000000000..e2e5d8dcc --- /dev/null +++ b/src/website/tests/shared/config.ts @@ -0,0 +1,4 @@ +export const SERVICE_HOST = + process.env.NEXT_PUBLIC_SERVER_HOST || 'http://localhost'; +export const SERVICE_PORT = process.env.NEXT_PUBLIC_SERVER_PORT || 3005; +export const SERVICE_URL = `${SERVICE_HOST}:${SERVICE_PORT}`; diff --git a/src/website/tests/shared/endpoints.ts b/src/website/tests/shared/endpoints.ts new file mode 100644 index 000000000..985fa346a --- /dev/null +++ b/src/website/tests/shared/endpoints.ts @@ -0,0 +1,7 @@ +export const GPS = `/api/gps`; +export const AISShips = `/api/aisships`; +export const GlobalPath = `/api/globalpath`; +export const LocalPath = `/api/localpath`; +export const Batteries = `/api/batteries`; +export const GenericSensors = `/api/generic-sensors`; +export const WindSensors = `/api/wind-sensors`; diff --git a/src/website/tests/shared/table-objects/table-objects.ts b/src/website/tests/shared/table-objects/table-objects.ts new file mode 100644 index 000000000..7b7fe5564 --- /dev/null +++ b/src/website/tests/shared/table-objects/table-objects.ts @@ -0,0 +1,6 @@ +export interface ResponseErrorObject { + detail?: string; + title?: string; + status?: string; + code?: string; +} diff --git a/src/website/tests/shared/utils/index.ts b/src/website/tests/shared/utils/index.ts new file mode 100644 index 000000000..9711a104e --- /dev/null +++ b/src/website/tests/shared/utils/index.ts @@ -0,0 +1 @@ +export { logger } from './utils'; diff --git a/src/website/tests/shared/utils/utils.ts b/src/website/tests/shared/utils/utils.ts new file mode 100644 index 000000000..e33c7d92a --- /dev/null +++ b/src/website/tests/shared/utils/utils.ts @@ -0,0 +1,21 @@ +import winston from 'winston'; + +const logFormat = winston.format.printf(function (log) { + return JSON.stringify(log.message, null, ' '); +}); + +export const logger = winston.createLogger({ + transports: [ + new winston.transports.Console({ + level: 'error', + format: logFormat, + }), + ], +}); + +export function convertBigIntToString(input: bigint): any { + if (typeof input == 'bigint') { + return input.toString(); + } + return input; +} diff --git a/src/website/tests/simulation/data/aisships.json b/src/website/tests/simulation/data/aisships.json new file mode 100644 index 000000000..2311f8c35 --- /dev/null +++ b/src/website/tests/simulation/data/aisships.json @@ -0,0 +1,56 @@ +[ + { + "ships": [ + { + "id": 0, + "latitude": 49.368762, + "longitude": -123.316688, + "cog": -120, + "rot": 45, + "sog": 12.5, + "width": 30, + "length": 80 + }, + { + "id": 1, + "latitude": 49.351795, + "longitude": -123.303052, + "cog": -20, + "rot": -20, + "sog": 12.5, + "width": 30, + "length": 100 + }, + { + "id": 2, + "latitude": 49.332968, + "longitude": -123.334462, + "cog": 0, + "rot": 0, + "sog": 12.5, + "width": 30, + "length": 150 + }, + { + "id": 3, + "latitude": 49.347048, + "longitude": -123.284458, + "cog": 10, + "rot": 10, + "sog": 12.5, + "width": 20, + "length": 180 + }, + { + "id": 4, + "latitude": 49.38638338038264, + "longitude": -123.27870728489948, + "cog": 80, + "rot": 80, + "sog": 12.5, + "width": 30, + "length": 200 + } + ] + } +] diff --git a/src/website/tests/simulation/data/batteries.json b/src/website/tests/simulation/data/batteries.json new file mode 100644 index 000000000..288a9b8ca --- /dev/null +++ b/src/website/tests/simulation/data/batteries.json @@ -0,0 +1,434 @@ +[ + { + "batteries": [ + { + "voltage": 8, + "current": 5 + }, + { + "voltage": 8, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 5 + }, + { + "voltage": 10, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 3 + }, + { + "voltage": 9, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 3 + }, + { + "voltage": 5, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 8, + "current": 5 + }, + { + "voltage": 5, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 4 + }, + { + "voltage": 10, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 4 + }, + { + "voltage": 5, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 5 + }, + { + "voltage": 5, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 5 + }, + { + "voltage": 5, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 4 + }, + { + "voltage": 7, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 4 + }, + { + "voltage": 6, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 5 + }, + { + "voltage": 8, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 4 + }, + { + "voltage": 10, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 5, + "current": 5 + }, + { + "voltage": 5, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 4 + }, + { + "voltage": 7, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 3 + }, + { + "voltage": 6, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 7, + "current": 4 + }, + { + "voltage": 6, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 7, + "current": 5 + }, + { + "voltage": 7, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 5 + }, + { + "voltage": 6, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 4 + }, + { + "voltage": 10, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 4 + }, + { + "voltage": 8, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 5 + }, + { + "voltage": 7, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 4 + }, + { + "voltage": 9, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 3 + }, + { + "voltage": 7, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 4 + }, + { + "voltage": 5, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 7, + "current": 3 + }, + { + "voltage": 10, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 4 + }, + { + "voltage": 7, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 7, + "current": 3 + }, + { + "voltage": 7, + "current": 4 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 4 + }, + { + "voltage": 7, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 6, + "current": 3 + }, + { + "voltage": 7, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 10, + "current": 3 + }, + { + "voltage": 5, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 4 + }, + { + "voltage": 7, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 8, + "current": 5 + }, + { + "voltage": 9, + "current": 3 + } + ] + }, + { + "batteries": [ + { + "voltage": 9, + "current": 4 + }, + { + "voltage": 5, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 5, + "current": 5 + }, + { + "voltage": 5, + "current": 5 + } + ] + }, + { + "batteries": [ + { + "voltage": 7, + "current": 3 + }, + { + "voltage": 6, + "current": 4 + } + ] + } +] diff --git a/src/website/tests/simulation/data/globalpath.json b/src/website/tests/simulation/data/globalpath.json new file mode 100644 index 000000000..d5793b9cc --- /dev/null +++ b/src/website/tests/simulation/data/globalpath.json @@ -0,0 +1,70 @@ +[ + { + "waypoints": [ + { + "latitude": 49.37614179786771, + "longitude": -123.27376619978901 + }, + { + "latitude": 49.37711663428484, + "longitude": -123.27156381625609 + }, + { + "latitude": 49.378315644557176, + "longitude": -123.27180418927239 + }, + { + "latitude": 49.381465588831524, + "longitude": -123.27254420646906 + }, + { + "latitude": 49.3839035958063, + "longitude": -123.2730793585836 + }, + { + "latitude": 49.38650818896502, + "longitude": -123.27564156703514 + }, + { + "latitude": 49.38625857180026, + "longitude": -123.28177300276381 + }, + { + "latitude": 49.382587584844835, + "longitude": -123.29247537578034 + }, + { + "latitude": 49.37750287441669, + "longitude": -123.29684958339224 + }, + { + "latitude": 49.37046373776872, + "longitude": -123.3022011892728 + }, + { + "latitude": 49.362482757047864, + "longitude": -123.30864508742094 + }, + { + "latitude": 49.35300923158242, + "longitude": -123.31705995027019 + }, + { + "latitude": 49.34650411159584, + "longitude": -123.3237483126415 + }, + { + "latitude": 49.34356040541922, + "longitude": -123.34073692035749 + }, + { + "latitude": 49.342421649614984, + "longitude": -123.34839354509414 + }, + { + "latitude": 49.34175775635472, + "longitude": -123.35453636335373 + } + ] + } +] diff --git a/src/website/tests/simulation/data/gps.json b/src/website/tests/simulation/data/gps.json new file mode 100644 index 000000000..db14dfeba --- /dev/null +++ b/src/website/tests/simulation/data/gps.json @@ -0,0 +1,218 @@ +[ + { + "latitude": 49.37614179786771, + "longitude": -123.27376619978901, + "speed": 12, + "heading": 135 + }, + { + "latitude": 49.376629216076275, + "longitude": -123.27266500802254, + "speed": 18, + "heading": -25 + }, + { + "latitude": 49.37711663428484, + "longitude": -123.27156381625609, + "speed": 16, + "heading": 44 + }, + { + "latitude": 49.37771613942101, + "longitude": -123.27168400276423, + "speed": 19, + "heading": -148 + }, + { + "latitude": 49.378315644557176, + "longitude": -123.27180418927239, + "speed": 14, + "heading": 178 + }, + { + "latitude": 49.37989061669436, + "longitude": -123.27217419787073, + "speed": 11, + "heading": 177 + }, + { + "latitude": 49.381465588831524, + "longitude": -123.27254420646906, + "speed": 19, + "heading": 36 + }, + { + "latitude": 49.38268459231891, + "longitude": -123.27281178252633, + "speed": 22, + "heading": -36 + }, + { + "latitude": 49.3839035958063, + "longitude": -123.2730793585836, + "speed": 13, + "heading": 35 + }, + { + "latitude": 49.385205892385656, + "longitude": -123.27436046280937, + "speed": 19, + "heading": 39 + }, + { + "latitude": 49.38650818896502, + "longitude": -123.27564156703514, + "speed": 18, + "heading": 75 + }, + { + "latitude": 49.386785594482504, + "longitude": -123.27689028351757, + "speed": 21, + "heading": 79 + }, + { + "latitude": 49.387063, + "longitude": -123.278139, + "speed": 11, + "heading": -120 + }, + { + "latitude": 49.387032000000005, + "longitude": -123.279635, + "speed": 19, + "heading": -40 + }, + { + "latitude": 49.387001, + "longitude": -123.281131, + "speed": 11, + "heading": -170 + }, + { + "latitude": 49.386629785900126, + "longitude": -123.28145200138191, + "speed": 10, + "heading": 177 + }, + { + "latitude": 49.38625857180026, + "longitude": -123.28177300276381, + "speed": 16, + "heading": -82 + }, + { + "latitude": 49.38442307832254, + "longitude": -123.28712418927208, + "speed": 19, + "heading": -2 + }, + { + "latitude": 49.382587584844835, + "longitude": -123.29247537578034, + "speed": 11, + "heading": 132 + }, + { + "latitude": 49.38004522963077, + "longitude": -123.2946624795863, + "speed": 12, + "heading": -68 + }, + { + "latitude": 49.37750287441669, + "longitude": -123.29684958339224, + "speed": 11, + "heading": -144 + }, + { + "latitude": 49.37398330609271, + "longitude": -123.29952538633253, + "speed": 20, + "heading": 52 + }, + { + "latitude": 49.37046373776872, + "longitude": -123.3022011892728, + "speed": 17, + "heading": -34 + }, + { + "latitude": 49.366473247408294, + "longitude": -123.30542313834687, + "speed": 12, + "heading": 129 + }, + { + "latitude": 49.362482757047864, + "longitude": -123.30864508742094, + "speed": 15, + "heading": 173 + }, + { + "latitude": 49.35774599431514, + "longitude": -123.31285251884557, + "speed": 14, + "heading": -113 + }, + { + "latitude": 49.35300923158242, + "longitude": -123.31705995027019, + "speed": 17, + "heading": 14 + }, + { + "latitude": 49.349756671589134, + "longitude": -123.32040413145585, + "speed": 13, + "heading": -129 + }, + { + "latitude": 49.34650411159584, + "longitude": -123.3237483126415, + "speed": 17, + "heading": 43 + }, + { + "latitude": 49.34503225850753, + "longitude": -123.33224261649947, + "speed": 20, + "heading": 77 + }, + { + "latitude": 49.34356040541922, + "longitude": -123.34073692035749, + "speed": 18, + "heading": 134 + }, + { + "latitude": 49.3429910275171, + "longitude": -123.34456523272581, + "speed": 17, + "heading": -5 + }, + { + "latitude": 49.342421649614984, + "longitude": -123.34839354509414, + "speed": 11, + "heading": -37 + }, + { + "latitude": 49.34208970298485, + "longitude": -123.35146495422393, + "speed": 22, + "heading": 44 + }, + { + "latitude": 49.34175775635472, + "longitude": -123.35453636335373, + "speed": 20, + "heading": 72 + }, + { + "latitude": 49.34175775635472, + "longitude": -123.35453636335373, + "speed": 12, + "heading": 111 + } +] diff --git a/src/website/tests/simulation/data/localpath.json b/src/website/tests/simulation/data/localpath.json new file mode 100644 index 000000000..6f57a3ffa --- /dev/null +++ b/src/website/tests/simulation/data/localpath.json @@ -0,0 +1,210 @@ +[ + { + "waypoints": [ + { + "latitude": 49.37614179786771, + "longitude": -123.27376619978901 + }, + { + "latitude": 49.37711663428484, + "longitude": -123.27156381625609 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.37711663428484, + "longitude": -123.27156381625609 + }, + { + "latitude": 49.378315644557176, + "longitude": -123.27180418927239 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.378315644557176, + "longitude": -123.27180418927239 + }, + { + "latitude": 49.381465588831524, + "longitude": -123.27254420646906 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.381465588831524, + "longitude": -123.27254420646906 + }, + { + "latitude": 49.3839035958063, + "longitude": -123.2730793585836 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.3839035958063, + "longitude": -123.2730793585836 + }, + { + "latitude": 49.38650818896502, + "longitude": -123.27564156703514 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.38650818896502, + "longitude": -123.27564156703514 + }, + { + "latitude": 49.387063, + "longitude": -123.278139 + }, + { + "latitude": 49.387063, + "longitude": -123.278139 + }, + { + "latitude": 49.387001, + "longitude": -123.281131 + }, + { + "latitude": 49.387001, + "longitude": -123.281131 + }, + { + "latitude": 49.38625857180026, + "longitude": -123.28177300276381 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.38625857180026, + "longitude": -123.28177300276381 + }, + { + "latitude": 49.382587584844835, + "longitude": -123.29247537578034 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.382587584844835, + "longitude": -123.29247537578034 + }, + { + "latitude": 49.37750287441669, + "longitude": -123.29684958339224 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.37750287441669, + "longitude": -123.29684958339224 + }, + { + "latitude": 49.37046373776872, + "longitude": -123.3022011892728 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.37046373776872, + "longitude": -123.3022011892728 + }, + { + "latitude": 49.362482757047864, + "longitude": -123.30864508742094 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.362482757047864, + "longitude": -123.30864508742094 + }, + { + "latitude": 49.35300923158242, + "longitude": -123.31705995027019 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.35300923158242, + "longitude": -123.31705995027019 + }, + { + "latitude": 49.34650411159584, + "longitude": -123.3237483126415 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.34650411159584, + "longitude": -123.3237483126415 + }, + { + "latitude": 49.34356040541922, + "longitude": -123.34073692035749 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.34356040541922, + "longitude": -123.34073692035749 + }, + { + "latitude": 49.342421649614984, + "longitude": -123.34839354509414 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.342421649614984, + "longitude": -123.34839354509414 + }, + { + "latitude": 49.34175775635472, + "longitude": -123.35453636335373 + } + ] + }, + { + "waypoints": [ + { + "latitude": 49.34175775635472, + "longitude": -123.35453636335373 + }, + { + "latitude": 49.34175775635472, + "longitude": -123.35453636335373 + } + ] + } +] diff --git a/src/website/tests/simulation/data/wind_sensors.json b/src/website/tests/simulation/data/wind_sensors.json new file mode 100644 index 000000000..5208b0689 --- /dev/null +++ b/src/website/tests/simulation/data/wind_sensors.json @@ -0,0 +1,434 @@ +[ + { + "windSensors": [ + { + "speed": 15, + "direction": -57 + }, + { + "speed": 11, + "direction": -34 + } + ] + }, + { + "windSensors": [ + { + "speed": 18, + "direction": -1 + }, + { + "speed": 10, + "direction": -143 + } + ] + }, + { + "windSensors": [ + { + "speed": 13, + "direction": 2 + }, + { + "speed": 19, + "direction": -136 + } + ] + }, + { + "windSensors": [ + { + "speed": 22, + "direction": 23 + }, + { + "speed": 17, + "direction": 174 + } + ] + }, + { + "windSensors": [ + { + "speed": 13, + "direction": 74 + }, + { + "speed": 22, + "direction": 15 + } + ] + }, + { + "windSensors": [ + { + "speed": 12, + "direction": -136 + }, + { + "speed": 16, + "direction": -169 + } + ] + }, + { + "windSensors": [ + { + "speed": 21, + "direction": -29 + }, + { + "speed": 15, + "direction": -123 + } + ] + }, + { + "windSensors": [ + { + "speed": 11, + "direction": 68 + }, + { + "speed": 14, + "direction": 85 + } + ] + }, + { + "windSensors": [ + { + "speed": 14, + "direction": 142 + }, + { + "speed": 20, + "direction": -139 + } + ] + }, + { + "windSensors": [ + { + "speed": 10, + "direction": 117 + }, + { + "speed": 13, + "direction": 12 + } + ] + }, + { + "windSensors": [ + { + "speed": 22, + "direction": -175 + }, + { + "speed": 21, + "direction": -161 + } + ] + }, + { + "windSensors": [ + { + "speed": 20, + "direction": -140 + }, + { + "speed": 19, + "direction": -29 + } + ] + }, + { + "windSensors": [ + { + "speed": 12, + "direction": -34 + }, + { + "speed": 14, + "direction": 31 + } + ] + }, + { + "windSensors": [ + { + "speed": 11, + "direction": 149 + }, + { + "speed": 13, + "direction": 106 + } + ] + }, + { + "windSensors": [ + { + "speed": 20, + "direction": -30 + }, + { + "speed": 11, + "direction": 125 + } + ] + }, + { + "windSensors": [ + { + "speed": 17, + "direction": -168 + }, + { + "speed": 19, + "direction": -160 + } + ] + }, + { + "windSensors": [ + { + "speed": 16, + "direction": 3 + }, + { + "speed": 13, + "direction": 138 + } + ] + }, + { + "windSensors": [ + { + "speed": 14, + "direction": -125 + }, + { + "speed": 21, + "direction": -108 + } + ] + }, + { + "windSensors": [ + { + "speed": 18, + "direction": -126 + }, + { + "speed": 16, + "direction": -123 + } + ] + }, + { + "windSensors": [ + { + "speed": 10, + "direction": 21 + }, + { + "speed": 22, + "direction": -178 + } + ] + }, + { + "windSensors": [ + { + "speed": 16, + "direction": -5 + }, + { + "speed": 21, + "direction": -111 + } + ] + }, + { + "windSensors": [ + { + "speed": 19, + "direction": -36 + }, + { + "speed": 19, + "direction": 111 + } + ] + }, + { + "windSensors": [ + { + "speed": 13, + "direction": 1 + }, + { + "speed": 18, + "direction": 7 + } + ] + }, + { + "windSensors": [ + { + "speed": 17, + "direction": 69 + }, + { + "speed": 15, + "direction": -49 + } + ] + }, + { + "windSensors": [ + { + "speed": 10, + "direction": 163 + }, + { + "speed": 10, + "direction": -96 + } + ] + }, + { + "windSensors": [ + { + "speed": 16, + "direction": -157 + }, + { + "speed": 18, + "direction": 73 + } + ] + }, + { + "windSensors": [ + { + "speed": 19, + "direction": 67 + }, + { + "speed": 15, + "direction": -39 + } + ] + }, + { + "windSensors": [ + { + "speed": 14, + "direction": -104 + }, + { + "speed": 14, + "direction": 164 + } + ] + }, + { + "windSensors": [ + { + "speed": 22, + "direction": 167 + }, + { + "speed": 15, + "direction": 2 + } + ] + }, + { + "windSensors": [ + { + "speed": 19, + "direction": 179 + }, + { + "speed": 21, + "direction": 29 + } + ] + }, + { + "windSensors": [ + { + "speed": 19, + "direction": -34 + }, + { + "speed": 16, + "direction": -115 + } + ] + }, + { + "windSensors": [ + { + "speed": 11, + "direction": 175 + }, + { + "speed": 12, + "direction": -51 + } + ] + }, + { + "windSensors": [ + { + "speed": 15, + "direction": -159 + }, + { + "speed": 15, + "direction": -102 + } + ] + }, + { + "windSensors": [ + { + "speed": 21, + "direction": 67 + }, + { + "speed": 17, + "direction": -129 + } + ] + }, + { + "windSensors": [ + { + "speed": 17, + "direction": 133 + }, + { + "speed": 17, + "direction": 81 + } + ] + }, + { + "windSensors": [ + { + "speed": 13, + "direction": -172 + }, + { + "speed": 12, + "direction": -138 + } + ] + } +] diff --git a/src/website/tests/simulation/generate_battery_windsensors.py b/src/website/tests/simulation/generate_battery_windsensors.py new file mode 100644 index 000000000..2a2e05586 --- /dev/null +++ b/src/website/tests/simulation/generate_battery_windsensors.py @@ -0,0 +1,76 @@ +import json +import random + +# Constants +no_of_objects = 36 + +# voltage (volts) +max_voltage = 10 +min_voltage = 5 + +# current (amperes) +max_current = 5 +min_current = 3 + +# speed (km/h) +max_speed = 22 +min_speed = 10 + +# angle convention (degrees) +min_direction = -180 +max_direction = 180 + + +def write_battery_json(): + battery_data = [] + + for i in range(no_of_objects): + battery_data.append( + { + "batteries": [ + { + "voltage": random.randint(min_voltage, max_voltage), + "current": random.randint(min_current, max_current), + }, + { + "voltage": random.randint(min_voltage, max_voltage), + "current": random.randint(min_current, max_current), + }, + ] + } + ) + + with open("./tests/simulation/data/batteries.json", "w") as f: + json.dump(battery_data, f, indent=4) + + +def write_wind_sensors_json(): + wind_sensors_data = [] + + for i in range(no_of_objects): + wind_sensors_data.append( + { + "windSensors": [ + { + "speed": random.randint(min_speed, max_speed), + "direction": random.randint(min_direction, max_direction), + }, + { + "speed": random.randint(min_speed, max_speed), + "direction": random.randint(min_direction, max_direction), + }, + ] + } + ) + + with open("./tests/simulation/data/wind_sensors.json", "w") as f: + json.dump(wind_sensors_data, f, indent=4) + + +def main(): + write_battery_json() + write_wind_sensors_json() + + +if __name__ == "__main__": + main() diff --git a/src/website/tests/simulation/global_path_to_local_path.py b/src/website/tests/simulation/global_path_to_local_path.py new file mode 100644 index 000000000..fc50eb814 --- /dev/null +++ b/src/website/tests/simulation/global_path_to_local_path.py @@ -0,0 +1,58 @@ +"""Converts the global path to a series of local path waypoints.""" + +import argparse +import json + + +def read_json_file(input_file): + with open(input_file, "r") as file: + data = json.load(file) + return data + + +def write_json_file(output_file, data): + new_data = [] + + for i in range(len(data[0]['waypoints'])): + if i == len(data[0]['waypoints']) - 1: + break + new_data.append( + { + "waypoints": [ + { + "latitude": data[0]['waypoints'][i]['latitude'], + "longitude": data[0]['waypoints'][i]['longitude'] + }, + { + "latitude": data[0]['waypoints'][i + 1]['latitude'], + "longitude": data[0]['waypoints'][i + 1]['longitude'] + }, + ] + } + ) + with open(output_file, "w") as file: + json.dump(new_data, file, indent=4) + + +def main(): + parser = argparse.ArgumentParser( + description="Read from an input JSON file and write to an output JSON file" + ) + parser.add_argument("input_file", help="Input JSON file name") + parser.add_argument("output_file", help="Output JSON file name") + args = parser.parse_args() + + input_file_name = args.input_file + output_file_name = args.output_file + + # Read data from the input JSON file + data_from_input = read_json_file(input_file_name) + + # Write data to the output JSON file + write_json_file(output_file_name, data_from_input) + + print(f"Data has been read from {input_file_name} and written to {output_file_name}.") + + +if __name__ == "__main__": + main() diff --git a/src/website/tests/simulation/linear_interpolation.py b/src/website/tests/simulation/linear_interpolation.py new file mode 100644 index 000000000..0da9fcb49 --- /dev/null +++ b/src/website/tests/simulation/linear_interpolation.py @@ -0,0 +1,68 @@ +"""Interpolates points on a line between two waypoints. Used to create a series of GPS points based on the local path.""" + +import argparse +import json +from math import radians, sin, cos, sqrt, atan2 + +from shapely.geometry import LineString, Point +from geopy.distance import geodesic + + +def get_points_along_line(start, end): + line = LineString([start, end]) + + points = [start] + points.append((line.centroid.x, line.centroid.y)) + + points.append(end) + return points + +def read_json_file(input_file): + with open(input_file, "r") as file: + data = json.load(file) + return data + +def write_json_file(output_file, data): + doc = [] + for i in range(len(data)): + start = data[i]["waypoints"][0] + end = data[i]["waypoints"][1] + waypoints = get_points_along_line( + (start['latitude'], start['longitude']), + (end['latitude'], end['longitude']) + ) + waypoints.pop() + + start["speed"] = 0 + start["heading"] = 0 + end["speed"] = 0 + end["heading"] = 0 + + for point in waypoints: + doc.append({"latitude": point[0], "longitude": point[1], "speed": 0, "heading": 0}) + + with open(output_file, "w") as file: + json.dump(doc, file, indent=4) + +def main(): + parser = argparse.ArgumentParser( + description="Read from an input JSON file and write to an output JSON file" + ) + parser.add_argument("input_file", help="Input JSON file name") + parser.add_argument("output_file", help="Output JSON file name") + args = parser.parse_args() + + input_file_name = args.input_file + output_file_name = args.output_file + + # Read data from the input JSON file + data_from_input = read_json_file(input_file_name) + + # Write data to the output JSON file + write_json_file(output_file_name, data_from_input) + + print(f"Data has been read from {input_file_name} and written to {output_file_name}.") + + +if __name__ == "__main__": + main() diff --git a/src/website/tests/simulation/modify_gps.py b/src/website/tests/simulation/modify_gps.py new file mode 100644 index 000000000..ca5c7ca8a --- /dev/null +++ b/src/website/tests/simulation/modify_gps.py @@ -0,0 +1,40 @@ +import json +import random + +# speed (km/h) +max_speed = 22 +min_speed = 10 + +# angle convention (degrees) +min_direction = -180 +max_direction = 180 + +input_file = "./tests/simulation/data/gps.json" + + +def modify_gps(): + with open(input_file, "r") as file: + data = json.load(file) + + new_data = [] + + for i in range(len(data)): + new_data.append( + { + "latitude": data[i]["latitude"], + "longitude": data[i]["longitude"], + "speed": random.randint(min_speed, max_speed), + "heading": random.randint(min_direction, max_direction) + } + ) + + with open("./tests/simulation/data/gps.json", "w") as f: + json.dump(new_data, f, indent=4) + + +def main(): + modify_gps() + + +if __name__ == "__main__": + main() diff --git a/src/website/tests/simulation/simulation.py b/src/website/tests/simulation/simulation.py new file mode 100644 index 000000000..5930f58a4 --- /dev/null +++ b/src/website/tests/simulation/simulation.py @@ -0,0 +1,137 @@ +""" +Pathfinding simulation for the website, using GPS, local path, global path, and AIS. + +Requirements: +- Ensure the DB is cleared and running. +- Ensure the env var 'MONGODB_URI' in the file '.env.local' is connected to correct the database name. +- Ensure the env var 'NEXT_PUBLIC_POLLING_TIME_MS' is set to 500 in the file '.env.local'. +""" + +import json +import time + +import pymongo +from datetime import datetime + +CONNECTION_STRING = "mongodb://localhost:27017" +DATABASE_NAME = "TestDB" + +# Load the database +client = pymongo.MongoClient(CONNECTION_STRING) +db = client[DATABASE_NAME] + + +def read_json_file(file_name): + with open(file_name, "r") as file: + data = json.load(file) + return data + + +# Load all data files +gps_data = read_json_file("./data/gps.json") +local_path_data = read_json_file("./data/localpath.json") +global_path_data = read_json_file("./data/globalpath.json") +ais_ships_data = read_json_file("./data/aisships.json") +batteries_data = read_json_file("./data/batteries.json") +wind_sensors_data = read_json_file("./data/wind_sensors.json") + +# Load all database collections +gps = db["gps"] +local_path = db["localpaths"] +global_path = db["globalpaths"] +ais_ships = db["aisships"] +batteries = db["batteries"] +wind_sensors = db["windsensors"] + + +def write_to_mongodb(data, collection): + data['timestamp'] = datetime.now().isoformat() + collection.insert_one(data) + print(f"Data written to MongoDB collection '{collection.name}'") + + +def clear_mongodb_collection(collection): + collection.delete_many({}) + print(f"Cleared data in MongoDB collection '{collection.name}'") + + +def preload_data(): + print("\nPreloading Data...\n") + write_to_mongodb(global_path_data[0], global_path) + write_to_mongodb(local_path_data[0], local_path) + write_to_mongodb(ais_ships_data[0], ais_ships) + print("\nDone\n") + + +def clear(): + print("\nClearing all collections:\n") + clear_mongodb_collection(gps) + clear_mongodb_collection(local_path) + clear_mongodb_collection(global_path) + clear_mongodb_collection(ais_ships) + clear_mongodb_collection(batteries) + clear_mongodb_collection(wind_sensors) + print("\nCleared all collections\n") + + +def display_help(): + print("\nAvailable options:") + print("clear - Clears the MongoDB database.") + print( + "preload - Writes all local path, global path, and ais ships data into the database to prepare the simulation." + ) + print("start - Starts the simulation on the website by periodically updating the gps.") + print( + "restart - Restarts the simulation on the website. Automatically clears and preloads the data into the database." + ) + print("exit - Exits the script.\n") + + +while True: + user_input = input("Enter your command: ") + + if user_input.lower() == "preload": + preload_data() + elif user_input.lower() == "clear": + clear() + elif user_input.lower() == "restart": + clear() + preload_data() + j = 0 + time.sleep(2) + for i in range(1, len(gps_data)): + write_to_mongodb(batteries_data[i], batteries) + write_to_mongodb(wind_sensors_data[i], wind_sensors) + write_to_mongodb(gps_data[i], gps) + lp_len = len(local_path_data[j]["waypoints"]) - 1 + if ( + local_path_data[j]["waypoints"][lp_len]["latitude"] == gps_data[i]["latitude"] + and local_path_data[j]["waypoints"][lp_len]["longitude"] + == gps_data[i]["longitude"] + ): + time.sleep(1) + j += 1 + if j < len(local_path_data): + write_to_mongodb(local_path_data[j], local_path) + time.sleep(1) + elif user_input.lower() == "start": + j = 0 + time.sleep(2) + for i in range(1, len(gps_data)): + write_to_mongodb(batteries_data[i], batteries) + write_to_mongodb(wind_sensors_data[i], wind_sensors) + write_to_mongodb(gps_data[i], gps) + if ( + local_path_data[j]["waypoints"][1]["latitude"] == gps_data[i]["latitude"] + and local_path_data[j]["waypoints"][1]["longitude"] == gps_data[i]["longitude"] + ): + time.sleep(1) + j += 1 + write_to_mongodb(local_path_data[j], local_path) + time.sleep(1) + elif user_input.lower() == "exit": + break + elif user_input.lower() == "help": + display_help() + else: + print("\nInvalid input. Type 'help' for assistance.\n") diff --git a/src/website/tests/steps/common.ts b/src/website/tests/steps/common.ts new file mode 100644 index 000000000..ccb96704b --- /dev/null +++ b/src/website/tests/steps/common.ts @@ -0,0 +1,26 @@ +import { Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import { api } from '../shared/classes/api'; + +Then('the service response is {int}', async function (status: number) { + expect(api.response.status).to.eq( + status, + 'Response status is not as expected', + ); +}); + +Then('the service error response is {int}', async function (status: number) { + expect(api.error.response.status).to.equal( + status, + 'Error status is not as expected', + ); +}); + +Then('the service success response is {int}', async function (status: number) { + expect(api.response.status).to.equal( + status, + 'Success status is not as expected', + ); +}); + +export {}; diff --git a/src/website/tests/steps/given.ts b/src/website/tests/steps/given.ts new file mode 100644 index 000000000..4e851595d --- /dev/null +++ b/src/website/tests/steps/given.ts @@ -0,0 +1,407 @@ +import { expect } from 'chai'; +import { api } from '../shared/classes/api'; +import { Given, Then } from '@cucumber/cucumber'; +import GPS from '@/models/GPS'; +import ConnectMongoDB from '@/lib/mongodb'; +import AISShips from '@/models/AISShips'; +import GlobalPath from '@/models/GlobalPath'; +import LocalPath from '@/models/LocalPath'; +import Batteries from '@/models/Batteries'; +import GenericSensors from '@/models/GenericSensors'; +import { convertBigIntToString } from '../shared/utils/utils'; +import WindSensors from '@/models/WindSensors'; + +Given('I clear the database', async function () { + const db = await ConnectMongoDB(); + await GPS.deleteMany(); + await AISShips.deleteMany(); + await GlobalPath.deleteMany(); + await LocalPath.deleteMany(); + await Batteries.deleteMany(); + await GenericSensors.deleteMany(); + await WindSensors.deleteMany(); +}); + +Given('I insert GPS data into the database', async function () { + const gpsData = { + latitude: 49.2243, + longitude: 4.5552, + speed: 30, + heading: 45, + }; + await GPS.create(gpsData); +}); + +Given('I insert AISShips data into the database', async function () { + const aisshipData = { + ships: [ + { + id: 0, + latitude: 49.3481, + longitude: -123.6096, + cog: -120, + rot: 50, + sog: 12.5, + width: 10, + length: 80, + }, + { + id: 1, + latitude: 49.4567, + longitude: -123.3729, + cog: 75, + rot: 22, + sog: 18.2, + width: 20, + length: 120, + }, + { + id: 2, + latitude: 49.1728, + longitude: -123.4578, + cog: 15, + rot: 80, + sog: 20, + width: 30, + length: 200, + }, + ], + }; + await AISShips.create(aisshipData); +}); + +Given('I insert GlobalPath data into the database', async function () { + const globalPathData = { + waypoints: [ + { + latitude: 49.37614179786771, + longitude: -123.27376619978901, + }, + { + latitude: 49.37711663428484, + longitude: -123.27156381625609, + }, + { + latitude: 49.378315644557176, + longitude: -123.27180418927239, + }, + { + latitude: 49.381465588831524, + longitude: -123.27254420646906, + }, + { + latitude: 49.3839035958063, + longitude: -123.2730793585836, + }, + { + latitude: 49.38650818896502, + longitude: -123.27564156703514, + }, + { + latitude: 49.38625857180026, + longitude: -123.28177300276381, + }, + { + latitude: 49.382587584844835, + longitude: -123.29247537578034, + }, + { + latitude: 49.37750287441669, + longitude: -123.29684958339224, + }, + { + latitude: 49.37046373776872, + longitude: -123.3022011892728, + }, + { + latitude: 49.362482757047864, + longitude: -123.30864508742094, + }, + { + latitude: 49.35300923158242, + longitude: -123.31705995027019, + }, + { + latitude: 49.34650411159584, + longitude: -123.3237483126415, + }, + { + latitude: 49.34356040541922, + longitude: -123.34073692035749, + }, + { + latitude: 49.342421649614984, + longitude: -123.34839354509414, + }, + { + latitude: 49.34175775635472, + longitude: -123.35453636335373, + }, + ], + }; + await GlobalPath.create(globalPathData); +}); + +Given('I insert LocalPath data into the database', async function () { + const localPathData = { + waypoints: [ + { + latitude: 49.34356040541922, + longitude: -123.34073692035749, + }, + { + latitude: 49.342421649614984, + longitude: -123.34839354509414, + }, + { + latitude: 49.34175775635472, + longitude: -123.35453636335373, + }, + ], + }; + await LocalPath.create(localPathData); +}); + +Given('I insert Batteries data into the database', async function () { + const batteriesData = { + batteries: [ + { + voltage: -3.33, + current: -3.33, + }, + { + voltage: 3.33, + current: 3.33, + }, + ], + }; + await Batteries.create(batteriesData); +}); + +Given('I insert GenericSensors data into the database', async function () { + const genericSensorsData = { + genericSensors: [ + { + id: 1, + data: 123456, + }, + { + id: 2, + data: 123456, + }, + { + id: 3, + data: 123456, + }, + ], + }; + await GenericSensors.create(genericSensorsData); +}); + +Given('I insert WindSensors data into the database', async function () { + const windSensorsData = { + windSensors: [ + { + speed: 1.11, + direction: 1, + }, + { + speed: 2.22, + direction: 2, + }, + ], + }; + await WindSensors.create(windSensorsData); +}); + +Then('the response data matches the data in the database', async function () { + let apiResponseData_GPS; + let databaseData_GPS; + + databaseData_GPS = await GPS.find({}).then(function (gps) { + let transformedGPS = gps.map((data) => data.toJSON()); + return transformedGPS; + }); + + apiResponseData_GPS = api.response.data.data[0]; + + const propertiesToCompare = Object.keys(apiResponseData_GPS); + + for (const property of propertiesToCompare) { + expect(apiResponseData_GPS[property]).to.equal( + databaseData_GPS[0][property], + `Data in the response does not match data in the database for property: ${property}`, + ); + } +}); + +Then( + 'the response data matches the aisship data in the database', + async function () { + let apiResponseData_AISShips; + let databaseData_AISShips; + + databaseData_AISShips = await AISShips.find({}).then(function (aisships) { + let transformedAISShips = aisships.map((data) => data.toJSON()); + return transformedAISShips; + }); + + for (let i = 0; i < 3; i++) { + apiResponseData_AISShips = api.response.data.data[0].ships[i]; + + const propertiesToCompare = Object.keys(apiResponseData_AISShips); + + for (const property of propertiesToCompare) { + expect(apiResponseData_AISShips[property]).to.equal( + databaseData_AISShips[0].ships[i][property], + `Data in the response does not match data in the database for property: ${property}`, + ); + } + } + }, +); + +Then( + 'the response data matches the GlobalPath data in the database', + async function () { + let apiResponseData_GlobalPath; + let databaseData_GlobalPath; + + databaseData_GlobalPath = await GlobalPath.find({}).then( + function (globalpath) { + let transformedGlobalPath = globalpath.map((data) => data.toJSON()); + return transformedGlobalPath; + }, + ); + + for (let i = 0; i < 16; i++) { + apiResponseData_GlobalPath = api.response.data.data[0].waypoints[i]; + + const propertiesToCompare = Object.keys(apiResponseData_GlobalPath); + + for (const property of propertiesToCompare) { + expect(apiResponseData_GlobalPath[property]).to.equal( + databaseData_GlobalPath[0].waypoints[i][property], + `Data in the response does not match data in the database for property: ${property}`, + ); + } + } + }, +); + +Then( + 'the response data matches the LocalPath data in the database', + async function () { + let apiResponseData_LocalPath; + let databaseData_LocalPath; + + databaseData_LocalPath = await LocalPath.find({}).then( + function (localpath) { + let transformedLocalPath = localpath.map((data) => data.toJSON()); + return transformedLocalPath; + }, + ); + + for (let i = 0; i < 3; i++) { + apiResponseData_LocalPath = api.response.data.data[0].waypoints[i]; + + const propertiesToCompare = Object.keys(apiResponseData_LocalPath); + + for (const property of propertiesToCompare) { + expect(apiResponseData_LocalPath[property]).to.equal( + databaseData_LocalPath[0].waypoints[i][property], + `Data in the response does not match data in the database for property: ${property}`, + ); + } + } + }, +); + +Then( + 'the response data matches the Batteries data in the database', + async function () { + let apiResponseData_Batteries; + let databaseData_Batteries; + + databaseData_Batteries = await Batteries.find({}).then( + function (batteries) { + let transformedBatteries = batteries.map((data) => data.toJSON()); + return transformedBatteries; + }, + ); + + for (let i = 0; i < 2; i++) { + apiResponseData_Batteries = api.response.data.data[0].batteries[i]; + + const propertiesToCompare = Object.keys(apiResponseData_Batteries); + + for (const property of propertiesToCompare) { + expect(apiResponseData_Batteries[property]).to.equal( + databaseData_Batteries[0].batteries[i][property], + `Data in the response does not match data in the database for property: ${property}`, + ); + } + } + }, +); + +Then( + 'the response data matches the WindSensors data in the database', + async function () { + let apiResponseData_WindSensors; + let databaseData_WindSensors; + + databaseData_WindSensors = await WindSensors.find({}).then( + function (windsensors) { + let transformedWindSensors = windsensors.map((data) => data.toJSON()); + return transformedWindSensors; + }, + ); + + for (let i = 0; i < 2; i++) { + apiResponseData_WindSensors = api.response.data.data[0].windSensors[i]; + + const propertiesToCompare = Object.keys(apiResponseData_WindSensors); + + for (const property of propertiesToCompare) { + expect(apiResponseData_WindSensors[property]).to.equal( + databaseData_WindSensors[0].windSensors[i][property], + `Data in the response does not match data in the database for property: ${property}`, + ); + } + } + }, +); + +Then( + 'the response data matches the GenericSensors data in the database', + async function () { + let apiResponseData_GenericSensors; + let databaseData_GenericSensors; + + databaseData_GenericSensors = await GenericSensors.find({}).then( + function (genericsensors) { + let transformedGenericSensors = genericsensors.map((data) => + data.toJSON(), + ); + return transformedGenericSensors; + }, + ); + + for (let i = 0; i < 3; i++) { + apiResponseData_GenericSensors = + api.response.data.data[0].genericSensors[i]; + + const propertiesToCompare = Object.keys(apiResponseData_GenericSensors); + + for (const property of propertiesToCompare) { + expect(apiResponseData_GenericSensors[property]).to.equal( + convertBigIntToString( + databaseData_GenericSensors[0].genericSensors[i][property], + ), + `Data in the response does not match data in the database for property: ${property}`, + ); + } + } + }, +); diff --git a/src/website/tests/steps/interfaces.ts b/src/website/tests/steps/interfaces.ts new file mode 100644 index 000000000..27316f257 --- /dev/null +++ b/src/website/tests/steps/interfaces.ts @@ -0,0 +1,43 @@ +import { When } from '@cucumber/cucumber'; +import { api } from '../shared/classes/api'; +import { GPS } from '../shared/endpoints'; +import { AISShips } from '../shared/endpoints'; +import { GlobalPath } from '../shared/endpoints'; +import { LocalPath } from '../shared/endpoints'; +import { Batteries } from '../shared/endpoints'; +import { GenericSensors } from '../shared/endpoints'; +import { WindSensors } from '../shared/endpoints'; + +When('I get all GPS interface data', async function () { + this.lastResponse = await api.get(GPS, this.config); +}); + +When('I try to get all GPS interface data', async function () { + this.lastResponse = await api.get(GPS, this.config, false); +}); + +When('I get all AISShip interface data', async function () { + this.lastResponse = await api.get(AISShips, this.config); +}); + +When('I get all GlobalPath interface data', async function () { + this.lastResponse = await api.get(GlobalPath, this.config); +}); + +When('I get all LocalPath interface data', async function () { + this.lastResponse = await api.get(LocalPath, this.config); +}); + +When('I get all Batteries interface data', async function () { + this.lastResponse = await api.get(Batteries, this.config); +}); + +When('I get all GenericSensors interface data', async function () { + this.lastResponse = await api.get(GenericSensors, this.config); +}); + +When('I get all WindSensors interface data', async function () { + this.lastResponse = await api.get(WindSensors, this.config); +}); + +export {}; diff --git a/src/website/tests/tsconfig.json b/src/website/tests/tsconfig.json new file mode 100644 index 000000000..cc6b535ed --- /dev/null +++ b/src/website/tests/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@/*": ["../*"] + } + }, + "ts-node": { + "require": ["tsconfig-paths/register"] + } +} diff --git a/src/website/tests/world/index.ts b/src/website/tests/world/index.ts new file mode 100644 index 000000000..510ffdd6d --- /dev/null +++ b/src/website/tests/world/index.ts @@ -0,0 +1,3 @@ +import World from './world'; + +export default { World }; diff --git a/src/website/tests/world/world.ts b/src/website/tests/world/world.ts new file mode 100644 index 000000000..f6a9b2e6d --- /dev/null +++ b/src/website/tests/world/world.ts @@ -0,0 +1,16 @@ +import { setWorldConstructor } from '@cucumber/cucumber'; +import { api } from '../shared/classes/api'; + +export default class World { + public config: object = { + headers: {}, + params: {}, + }; + + constructor() { + api.response = undefined; + api.error = undefined; + } +} + +setWorldConstructor(World); diff --git a/src/website/tsconfig.json b/src/website/tsconfig.json new file mode 100644 index 000000000..086112dfc --- /dev/null +++ b/src/website/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "paths": { + "@/*": ["./*"] + }, + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/src/website/types/mongodb.d.ts b/src/website/types/mongodb.d.ts new file mode 100644 index 000000000..cd94e3ff2 --- /dev/null +++ b/src/website/types/mongodb.d.ts @@ -0,0 +1,5 @@ +import { MongoClient } from 'mongodb'; + +declare global { + var _mongoClientPromise: Promise; +} diff --git a/src/website/utils/BaseReducer.js b/src/website/utils/BaseReducer.js new file mode 100644 index 000000000..9eb8855fb --- /dev/null +++ b/src/website/utils/BaseReducer.js @@ -0,0 +1,19 @@ +export default class BaseReducer { + initialState = {}; + + reducer = (state = this.initialState, action) => { + // if the action type is used for a method name then this be a reference to + // that class method. + // if the action type is not found then the "method" const will be undefined. + const method = this[action.type]; + + // if the action type "method" const is undefined or the action is an error + // return the state. + if (!method || action.error) { + return state; + } + + // Calls the method with the correct "this" and returns the modified state. + return method.call(this, state, action); + }; +} diff --git a/src/website/utils/BaseSaga.js b/src/website/utils/BaseSaga.js new file mode 100644 index 000000000..add126f70 --- /dev/null +++ b/src/website/utils/BaseSaga.js @@ -0,0 +1,36 @@ +import { fork } from 'redux-saga/effects'; + +export default class BaseSaga { + /** + * Checks if a function is a worker saga. For this to be true, the function should end with the name 'Watcher'. + * + * Saga functions are separated into two classes: workers and watchers. + * - Watchers ensure that an action is dispatched to the redux store, if it matches the action they are told to handle. + * - Workers are assigned to a specific watcher and perform the specific action they are told to do. + * + * In our use case, we only want to execute watcher sagas since these functions are responsible for knowing when a specific + * worker saga should be dispatched. + * + * @param {*} fn - name of the function + * @returns true or false + */ + static isWatcher(fn) { + return fn.endsWith('Watcher'); + } + + /** + * Returns all saga functions to execute. + * + * @returns a list of saga functions. + */ + forkSagas() { + const sagaFunctions = []; + const functions = Object.getOwnPropertyNames(Object.getPrototypeOf(this)); + functions.forEach((func) => { + if (func !== 'constructor') { + sagaFunctions.push(fork([this, this[func]])); + } + }); + return sagaFunctions; + } +} diff --git a/src/website/views/DashboardContainer.tsx b/src/website/views/DashboardContainer.tsx new file mode 100644 index 000000000..84532e735 --- /dev/null +++ b/src/website/views/DashboardContainer.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import UPlotLineChartComponent from './components/LineChart/UPlotLineChart'; +import { GPSState } from '@/stores/GPS/GPSTypes'; +import { BatteriesState } from '@/stores/Batteries/BatteriesTypes'; +import { WindSensorsState } from '@/stores/WindSensors/WindSensorsTypes'; +import UPlotMultiLineChartComponent from './components/LineChart/UPlotMultiLineChart'; +import SingleValueChart from './components/SingleValueChart/SingleValueChart'; +import { Grid } from '@mui/material'; + +export interface DashboardContainerProps { + gps: GPSState; + batteries: BatteriesState; + windSensors: WindSensorsState; +} + +class DashboardContainer extends React.PureComponent { + render() { + const { gps, batteries, windSensors } = this.props; + + const gpsChartData = [ + gps.data.map((data) => this._parseISOString(data.timestamp)), + gps.data.map((data) => data.speed), + ]; + + const gpsDistanceData = [ + gps.data.map((data) => data.latitude), + gps.data.map((data) => data.longitude) + ]; + + const batteriesVoltageData = [ + batteries.data.map((data) => this._parseISOString(data.timestamp)), + batteries.data.map((data) => data.batteries[0].voltage), + batteries.data.map((data) => data.batteries[1].voltage), + ]; + + const batteriesCurrentData = [ + batteries.data.map((data) => this._parseISOString(data.timestamp)), + batteries.data.map((data) => data.batteries[0].current), + batteries.data.map((data) => data.batteries[1].current), + ]; + + const windSensorsSpeedData = [ + windSensors.data.map((data) => this._parseISOString(data.timestamp)), + windSensors.data.map((data) => data.windSensors[0].speed), + windSensors.data.map((data) => data.windSensors[1].speed), + ]; + + const totalTripDistance = this._computeTotalTripDistance(gpsDistanceData[0], gpsDistanceData[1]) + + return ( +
+ {/* + + + + + + + + + + + + + */} + + + + +
+ ); + } + + _parseISOString(s: string) { + return Math.floor(Date.parse(s) / 1000); // Converts to seconds + } + + _haversineDistance(lat1: number, long1: number, lat2: number, long2: number) { + + function toRadians(angle: number): number{ + return angle * Math.PI / 180 + } + + const EARTH_RADIUS = 6571 // in km + + let delta_lat = lat2-lat1 + let delta_lat_rad = toRadians(delta_lat) + let delta_long = long2-long1 + let delta_long_rad = toRadians(delta_long) + + let a = (Math.sin(delta_lat_rad/2) * Math.sin(delta_lat_rad/2)) + + (Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) + * Math.sin(delta_long_rad/2) + * Math.sin(delta_long_rad/2)) + let c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + + let distance = EARTH_RADIUS * c; + + return distance + } + + _computeTotalTripDistance(latitude: number[], longitude: number[]) { + if(latitude.length != longitude.length){ + return -1; + } + + let totalDistance = 0; + + for(let i = 1; i < latitude.length; i++){ + totalDistance += this._haversineDistance(latitude[i-1], longitude[i-1], latitude[i], longitude[i]); + } + + return Number(totalDistance.toFixed(2)); + } +} + +const mapStateToProps = (state: any) => ({ + gps: state.gps, + batteries: state.batteries, + windSensors: state.windSensors, +}); + +const mapDispatchToProps = {}; + +export default connect(mapStateToProps, mapDispatchToProps)(DashboardContainer); diff --git a/src/website/views/MapsContainer.tsx b/src/website/views/MapsContainer.tsx new file mode 100644 index 000000000..d1d88006b --- /dev/null +++ b/src/website/views/MapsContainer.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { GPS, GPSState } from '@/stores/GPS/GPSTypes'; +import { GlobalPathState } from '@/stores/GlobalPath/GlobalPathTypes'; +import { AISShipsState } from '@/stores/AISShips/AISShipsTypes'; +import { WayPoint, LocalPathState } from '@/stores/LocalPath/LocalPathTypes'; +import Maps, { convertToLatLng } from './components/Maps/Maps'; +import SingleValueLine from './components/SingleValueLine/SingleValueLine'; +import styles from './components/SingleValueLine/singlevalueline.module.css' +import BoatCompass from './components/BoatCompass/BoatCompass'; + +export interface MapsContainerProps { + gps: GPSState; + globalPath: GlobalPathState; + aisShips: AISShipsState; + localPath: LocalPathState; +} + +class MapsContainer extends React.PureComponent { + render() { + const { gps } = this.props; + + const gpsDistanceData = [ + gps.data.map((data) => data.latitude), + gps.data.map((data) => data.longitude) + ]; + + const totalTripDistance = this._computeTotalTripDistance(gpsDistanceData[0], gpsDistanceData[1]) + + return ( +
+
+ +
+
+ +
+ + convertToLatLng(gpsPoint), + )} + globalPath={this.props.globalPath.data.waypoints.map( + (waypoint: WayPoint) => convertToLatLng(waypoint), + )} + aisShips={this.props.aisShips.data.ships} + localPath={this.props.localPath.data.waypoints.map( + (waypoint: WayPoint) => convertToLatLng(waypoint), + )} + /> +
+ ); + } + + _haversineDistance(lat1: number, long1: number, lat2: number, long2: number) { + + function toRadians(angle: number): number{ + return angle * Math.PI / 180 + } + + const EARTH_RADIUS = 6571 // in km + + let delta_lat = lat2-lat1 + let delta_lat_rad = toRadians(delta_lat) + let delta_long = long2-long1 + let delta_long_rad = toRadians(delta_long) + + let a = (Math.sin(delta_lat_rad/2) * Math.sin(delta_lat_rad/2)) + + (Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) + * Math.sin(delta_long_rad/2) + * Math.sin(delta_long_rad/2)) + let c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + + let distance = EARTH_RADIUS * c; + + return distance + } + + _computeTotalTripDistance(latitude: number[], longitude: number[]) { + if(latitude.length != longitude.length){ + return -1; + } + + let totalDistance = 0; + + for(let i = 1; i < latitude.length; i++){ + totalDistance += this._haversineDistance(latitude[i-1], longitude[i-1], latitude[i], longitude[i]); + } + + return Number(totalDistance.toFixed(2)); + } +} + +const mapStateToProps = (state: any) => ({ + gps: state.gps, + globalPath: state.globalPath, + aisShips: state.aisShips, + localPath: state.localPath, +}); +const mapDispatchToProps = {}; + +export default connect(mapStateToProps, mapDispatchToProps)(MapsContainer); diff --git a/src/website/views/components/BoatCompass/BoatCompass.tsx b/src/website/views/components/BoatCompass/BoatCompass.tsx new file mode 100644 index 000000000..0c864681a --- /dev/null +++ b/src/website/views/components/BoatCompass/BoatCompass.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import Image from "next/image"; +import { Grid, Paper, Typography } from "@mui/material"; +import SingleValueLine from "../SingleValueLine/SingleValueLine"; +import styles from "./boatcompass.module.css" + +interface BoatCompassProps { + angle: number; +} + +class BoatCompass extends React.Component { + render() { + const { angle } = this.props; + + return ( + + + + {`${this._rotateBoat(angle).toFixed()}°`} + + + Boat Icon + Compass Background + + ); + } + + _rotateBoat(boatAngle: number) { + return (boatAngle < 0) ? boatAngle + 360 : boatAngle; + } + + _rotateBoatString(boatAngle: number) { + return `rotate(${this._rotateBoat(boatAngle)}deg)`; + } +} + +export default BoatCompass; diff --git a/src/website/views/components/BoatCompass/boatcompass.module.css b/src/website/views/components/BoatCompass/boatcompass.module.css new file mode 100644 index 000000000..2aaa86b21 --- /dev/null +++ b/src/website/views/components/BoatCompass/boatcompass.module.css @@ -0,0 +1,13 @@ +.bottom { + position: absolute; +} + +.middle { + position: absolute; + z-index: 1; +} + +.top { + position: relative; + z-index: 1000000000; +} diff --git a/src/website/views/components/Header/Header.tsx b/src/website/views/components/Header/Header.tsx new file mode 100644 index 000000000..71d238ec6 --- /dev/null +++ b/src/website/views/components/Header/Header.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './header.module.css'; + +function Header() { + return ( +
+ Logo +

UBC SAILBOT

+
+ ); +} + +export default Header; diff --git a/src/website/views/components/Header/header.module.css b/src/website/views/components/Header/header.module.css new file mode 100644 index 000000000..ca569bc5c --- /dev/null +++ b/src/website/views/components/Header/header.module.css @@ -0,0 +1,22 @@ +.header { + padding: 5px; + margin: -10px; + margin-bottom: 10px; + display: flex; + align-items: center; + background: linear-gradient(to right, #26619c, #3498db); +} + +.title { + font-family: Verdana, Geneva, sans-serif; + font-weight: 20px; + color: white; + margin-left: 20px; + font-size: 30px; + letter-spacing: 2px; +} + +.logo { + width: 30px; + margin-left: 50px; +} diff --git a/src/website/views/components/LineChart/LineChart.tsx b/src/website/views/components/LineChart/LineChart.tsx new file mode 100644 index 000000000..c276787d4 --- /dev/null +++ b/src/website/views/components/LineChart/LineChart.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; + +export interface ILineChartProps { + data: any[]; + xAxisKey: string; + yAxisKey: string; +} + +export interface ILineChartState {} + +export default class LineChartComponent extends React.Component< + ILineChartProps, + ILineChartState +> { + render() { + const { data, xAxisKey, yAxisKey } = this.props; + return ( + + + + + + + + + + + ); + } +} diff --git a/src/website/views/components/LineChart/MultiLineChart.tsx b/src/website/views/components/LineChart/MultiLineChart.tsx new file mode 100644 index 000000000..76279e2d2 --- /dev/null +++ b/src/website/views/components/LineChart/MultiLineChart.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; + +export interface MultiLineChartProps { + data: any[]; + xAxisKey: string; + yAxisKey1: string; + yAxisKey2: string; +} + +export interface MultiLineChartState {} + +export default class MultiLineChartComponent extends React.Component< + MultiLineChartProps, + MultiLineChartState +> { + render() { + const { data, xAxisKey, yAxisKey1, yAxisKey2 } = this.props; + return ( + + + + + + + + + + + + ); + } +} diff --git a/src/website/views/components/LineChart/UPlotLineChart.tsx b/src/website/views/components/LineChart/UPlotLineChart.tsx new file mode 100644 index 000000000..7364d0552 --- /dev/null +++ b/src/website/views/components/LineChart/UPlotLineChart.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import uPlot from 'uplot'; +import UplotReact from 'uplot-react'; +import 'uplot/dist/uPlot.min.css'; + +export interface IUPlotLineChartProps { + data: any[]; + label: string; + unit: string; +} + +export interface IUPlotLineChartState { + chart: uPlot; + options: uPlot.Options; +} + +const fmtDate = uPlot.fmtDate("{YYYY}-{MM}-{DD} {h}:{mm}:{ss}{aa}"); +const localTz = new Intl.DateTimeFormat().resolvedOptions().timeZone; +const tzDate = ts => uPlot.tzDate(new Date(ts * 1e3), localTz); +export default class UPlotLineChartComponent extends React.Component< + IUPlotLineChartProps, + IUPlotLineChartState +> { + readonly state: IUPlotLineChartState = { + chart: null, + options: { + width: 0, + height: 250, + scales: { + x: {}, + y: {}, + }, + axes: [{}], + series: [ + { + show: true, + spanGaps: false, + label: "Time", + value: (self, rawValue, xValuesIndex, currentVal) => { + if (currentVal == null) { + let xValues = self.data[xValuesIndex]; + let xValue = fmtDate(tzDate(xValues[xValues.length - 1])); + return `${xValue}`; + } + return fmtDate(tzDate(rawValue)); + }, + stroke: 'red', + width: 1, + band: true, + }, + { + show: true, + spanGaps: false, + label: this.props.label, + value: (self, rawValue, yValuesIndex, currentVal) => { + if (currentVal == null) { + let yValues = self.data[yValuesIndex]; + let yValue = (yValues[yValues.length - 1])?.toFixed(2); + return `${yValue} ${this.props.unit}`; + } + return rawValue?.toFixed(2) + ` ${this.props.unit}`; + }, + stroke: 'red', + width: 1, + band: true, + }, + ], + }, + }; + + componentDidMount() { + // Set the chart's width dynamically; the height is set manually above within 'options' in state. + this.setState((state) => ({ + ...state, + options: { ...state.options, width: this.getWindowSize().width / 2 - 40 }, + })); + + window.addEventListener('resize', this.setChartSize); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.setChartSize); + } + + /** + * Dynamically changes the chart's dimensions whenever the user resizes their window size. + * + * @param e the event called whenever a user resizes their window. + */ + setChartSize = (e: any) => { + this.state.chart.setSize({ + width: this.getWindowSize().width / 2 - 40, + height: this.state.options.height, + }); + }; + + /** + * @returns the current window size (width, height) of the user's device. + */ + getWindowSize = () => { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }; + + /** + * Sets the chart reference in the component's state. + * + * @param chart - The chart instance to be stored in the component's state. + * This instance is used for various chart operations within the component. + */ + setChartRef = (chart: any) => { + this.setState((state) => ({ ...state, chart: chart })); + }; + + render() { + return ( + + ); + } +} diff --git a/src/website/views/components/LineChart/UPlotMultiLineChart.tsx b/src/website/views/components/LineChart/UPlotMultiLineChart.tsx new file mode 100644 index 000000000..f666147d4 --- /dev/null +++ b/src/website/views/components/LineChart/UPlotMultiLineChart.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import uPlot from 'uplot'; +import UplotReact from 'uplot-react'; +import 'uplot/dist/uPlot.min.css'; + +export interface IUPlotMultiLineChartProps { + data: any[]; + labelOne: string; + labelTwo: string; + unit: string; +} + +export interface IUPlotMultiLineChartState { + chart: uPlot; + options: uPlot.Options; +} + +const fmtDate = uPlot.fmtDate("{YYYY}-{MM}-{DD} {h}:{mm}:{ss}{aa}"); +const localTz = new Intl.DateTimeFormat().resolvedOptions().timeZone; +const tzDate = ts => uPlot.tzDate(new Date(ts * 1e3), localTz); +export default class UPlotMultiLineChartComponent extends React.Component< + IUPlotMultiLineChartProps, + IUPlotMultiLineChartState +> { + readonly state: IUPlotMultiLineChartState = { + chart: null, + options: { + width: 0, + height: 250, + scales: { + x: {}, + y: {}, + }, + axes: [{}], + series: [ + { + show: true, + spanGaps: false, + label: "Time", + value: (self, rawValue, xValuesIndex, currentVal) => { + if (currentVal == null) { + let xValues = self.data[xValuesIndex]; + let xValue = fmtDate(tzDate(xValues[xValues.length - 1])); + return `${xValue}`; + } + return fmtDate(tzDate(rawValue)); + }, + stroke: 'red', + width: 1, + band: true, + }, + { + show: true, + spanGaps: false, + label: this.props.labelOne, + value: (self, rawValue, yValuesIndex, currentVal) => { + if (currentVal == null) { + let yValues = self.data[yValuesIndex] + let yValue = (yValues[yValues.length - 1])?.toFixed(2) + return `${yValue} ${this.props.unit}` + } + return rawValue?.toFixed(2) + ` ${this.props.unit}`; + }, + stroke: 'red', + width: 1, + band: true, + }, + { + show: true, + spanGaps: false, + label: this.props.labelTwo, + value: (self, rawValue, yValuesIndex, currentVal) => { + if (currentVal == null) { + let yValues = self.data[yValuesIndex] + let yValue = (yValues[yValues.length - 1])?.toFixed(2) + return `${yValue} ${this.props.unit}` + } + return rawValue?.toFixed(2) + ` ${this.props.unit}`; + }, + stroke: 'green', + width: 1, + band: true, + }, + ], + }, + }; + + componentDidMount() { + // Set the chart's width dynamically; the height is set manually above within 'options' in state. + this.setState((state) => ({ + ...state, + options: { ...state.options, width: this.getWindowSize().width / 2 - 40 }, + })); + + window.addEventListener('resize', this.setChartSize); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.setChartSize); + } + + /** + * Dynamically changes the chart's dimensions whenever the user resizes their window size. + * + * @param e the event called whenever a user resizes their window. + */ + setChartSize = (e: any) => { + this.state.chart.setSize({ + width: this.getWindowSize().width / 2 - 40, + height: this.state.options.height, + }); + }; + + /** + * @returns the current window size (width, height) of the user's device. + */ + getWindowSize = () => { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }; + + /** + * Sets the chart reference in the component's state. + * + * @param chart - The chart instance to be stored in the component's state. + * This instance is used for various chart operations within the component. + */ + setChartRef = (chart: any) => { + this.setState((state) => ({ ...state, chart: chart })); + }; + + render() { + return ( + + ); + } +} diff --git a/src/website/views/components/Maps/Maps.tsx b/src/website/views/components/Maps/Maps.tsx new file mode 100644 index 000000000..9d646e7ea --- /dev/null +++ b/src/website/views/components/Maps/Maps.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +/* Leaflet related imports */ +import { + MapContainer, + Marker, + Popup, + TileLayer, + Polyline, + LayersControl, + LayerGroup, + Polygon, +} from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import 'leaflet-defaulticon-compatibility'; +import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css'; +import L from 'leaflet'; +import 'leaflet-geometryutil'; +import { GPS } from '@/stores/GPS/GPSTypes'; +import { AISShip } from '@/stores/AISShips/AISShipsTypes'; + +export interface IMapsProps { + gpsLocation: GPS | undefined; + gpsPath: L.LatLngExpression[]; + globalPath: L.LatLngExpression[]; + localPath: L.LatLngExpression[]; + aisShips: AISShip[]; +} + +export interface IMapsState { + map: L.Map | null; +} + +/** + * Converts an object's properties into digestible text. + * For example, given an object {a: 1, b: 2}, the function will output the text: + * """ + * a: 1 + * b: 1 + * """ + * + * @param obj - the object + * @returns an array of the pretty printed objects + */ +export const printObjectInfo = (obj: any): any[] => { + let ele: any[] = []; + Object.keys(obj).forEach((key, i) => { + ele.push(`${key}: ${obj[key]}`,
); + }); + return ele; +}; + +/** + * Converts an object with latitude and longitude fields into an array. + * This is conversion is necessary for Leaflet. + * + * @param obj - object with fields latitude and longitude + * @returns an array containing [latitude, longitude] + */ +export const convertToLatLng = (obj: any): L.LatLngExpression => { + return L.latLng(obj.latitude, obj.longitude); +}; + +export default class Maps extends React.Component { + readonly state: IMapsState = { + map: null, + }; + + /** + * Sets the map reference in the component's state. + * + * @param map - The Leaflet map instance to be stored in the component's state. + * This instance is used for various map operations within the component. + */ + setMapRef = (map: L.Map) => { + this.setState((state) => ({ ...state, map: map })); + }; + + /** + * Rotates a point around a specified axis by a given angle. + * + * @param point - The point (latitude and longitude) to be rotated. Expects an L.LatLngExpression. + * @param angle - The angle of rotation in degrees. Positive values will rotate + * the point in a clockwise direction, while negative values rotate + * counterclockwise. + * @param axis - The axis point (latitude and longitude) around which the rotation + * will occur. Expects an L.LatLngExpression. + * @returns - The rotated point as an L.LatLngExpression. If the map is not + * initialized, the original point is returned without modification. + */ + rotatePoint = ( + point: L.LatLngExpression, + angle: number, + axis: L.LatLngExpression, + ) => { + const map = this.state.map; + if (map == null) { + return point; + } + return L.GeometryUtil.rotatePoint(map, point, angle, axis); + }; + + /** + * Renders ships on a map as rectangles. Each ship is represented by a rectangle + * calculated based on its latitude, longitude, width, length, and course over ground (cog). + * The method calculates the coordinates for the corners of each rectangle, rotates them + * according to the ship's cog, and then renders a Polygon on the map for each ship. + * + * @returns - An array of React Polygon components. Each Polygon represents a ship and + * is positioned on the map based on the ship's data. The Polygons are styled + * with red borders and contain a Popup that shows the ship's information. + * If the aisShips prop is empty, no Polygons are rendered. + */ + renderShips = () => { + const EARTH_RADIUS_METERS = 6378000; + const PI = Math.PI; + + return this.props.aisShips.map((ship, index) => { + const { latitude, longitude, width, length, cog } = ship; + + // Assuming that length and width are in meters + const dy = length / 2; + const dx = width / 2; + + // Calculate the top left and bottom right coordinates of the rectangle + const newLatitudeNorth = + latitude + (dy / EARTH_RADIUS_METERS) * (180 / PI); + const newLongitudeWest = + longitude - + ((dx / EARTH_RADIUS_METERS) * (180 / PI)) / + Math.cos((latitude * PI) / 180); + const newLatitudeSouth = + latitude - (dy / EARTH_RADIUS_METERS) * (180 / PI); + const newLongitudeEast = + longitude + + ((dx / EARTH_RADIUS_METERS) * (180 / PI)) / + Math.cos((latitude * PI) / 180); + + const topLeftRotated = this.rotatePoint( + L.latLng(newLatitudeNorth, newLongitudeWest), + cog, + L.latLng(latitude, longitude), + ); + const topRightRotated = this.rotatePoint( + L.latLng(newLatitudeNorth, newLongitudeEast), + cog, + L.latLng(latitude, longitude), + ); + const bottomLeftRotated = this.rotatePoint( + L.latLng(newLatitudeSouth, newLongitudeWest), + cog, + L.latLng(latitude, longitude), + ); + const bottomRightRotated = this.rotatePoint( + L.latLng(newLatitudeSouth, newLongitudeEast), + cog, + L.latLng(latitude, longitude), + ); + + const bounds: L.LatLngExpression[] = [ + topLeftRotated, + topRightRotated, + bottomRightRotated, + bottomLeftRotated, + ]; + + // Rectangle options + const redOptions = { color: 'red' }; + + return ( + + {printObjectInfo(ship)} + + ); + }); + }; + + render() { + return ( + + + + + {this.renderShips()} + + + + + + + + + + {printObjectInfo(this.props.gpsLocation)} + + + + ); + } +} diff --git a/src/website/views/components/SingleValueChart/SingleValueChart.tsx b/src/website/views/components/SingleValueChart/SingleValueChart.tsx new file mode 100644 index 000000000..83f1803b5 --- /dev/null +++ b/src/website/views/components/SingleValueChart/SingleValueChart.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Typography } from '@mui/material'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import styles from './singlevaluechart.module.css' + + +interface SingleValueChartProps { + title: string; + data: number | string | undefined; + unit: string; +} + +class SingleValueChart extends React.Component { + render() { + const { title, data, unit } = this.props; + + return ( + + + + {`${title}`} + + + {(data) ? `${data} ${unit}` : `-- ${unit}`} + + + + ); + } +} + +export default SingleValueChart; diff --git a/src/website/views/components/SingleValueChart/singlevaluechart.module.css b/src/website/views/components/SingleValueChart/singlevaluechart.module.css new file mode 100644 index 000000000..a88cd0719 --- /dev/null +++ b/src/website/views/components/SingleValueChart/singlevaluechart.module.css @@ -0,0 +1,5 @@ +.card{ + display: block; + width: 15vw; + height: 13vh; +} diff --git a/src/website/views/components/SingleValueLine/SingleValueLine.tsx b/src/website/views/components/SingleValueLine/SingleValueLine.tsx new file mode 100644 index 000000000..85c825c5b --- /dev/null +++ b/src/website/views/components/SingleValueLine/SingleValueLine.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Grid, Paper, Typography } from '@mui/material'; + + +interface SingleValueLineProps { + title: string; + data: number | string | undefined; + unit: string; +} + +class SingleValueLine extends React.Component { + render() { + const { title, data, unit } = this.props; + + return ( + + + + + {(data) ? `${title}: ${data} ${unit}` : `-- ${unit}`} + + + + + ); + } +} + +export default SingleValueLine; diff --git a/src/website/views/components/SingleValueLine/singlevalueline.module.css b/src/website/views/components/SingleValueLine/singlevalueline.module.css new file mode 100644 index 000000000..99b7ad42c --- /dev/null +++ b/src/website/views/components/SingleValueLine/singlevalueline.module.css @@ -0,0 +1,17 @@ +.parent { + position: relative; +} + +.topRight { + position: absolute; + z-index: 10000000000; + top: 0.6vh; + right: 0.3vw; +} + +.bottomRight { + position: absolute; + z-index: 10000000000; + bottom: 3.1vh; + right: 0.5vw; +} diff --git a/src/website/views/components/checkbox/Checkboxes.tsx b/src/website/views/components/checkbox/Checkboxes.tsx new file mode 100644 index 000000000..d08e75f40 --- /dev/null +++ b/src/website/views/components/checkbox/Checkboxes.tsx @@ -0,0 +1,78 @@ +import React, { Component } from 'react'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import Paper from '@mui/material/Paper'; + +class Checkboxes extends React.Component { + constructor(props) { + super(props); + + this.handleButtonClick = this.handleButtonClick.bind(this); + + //initializing an array to track button states + this.state = { + // button1 button2 button3 + buttonStates: [false, false, false], + }; + } + + checkChecked(status, checkboxNumber) { + if (!status) { + console.log('hello, i am layer ' + checkboxNumber + '!'); + } + } + + handleButtonClick(index) { + let newArr = [...this.state.buttonStates]; + newArr[index] = !newArr[index]; + this.setState({ buttonStates: newArr }); + this.checkChecked(this.state.buttonStates[index], ++index); + } + + render() { + const { buttonStates } = this.state; + + return ( + + + + this.handleButtonClick(0)} + /> + } + label='Layer 1' + labelPlacement='bottom' + /> + this.handleButtonClick(1)} + /> + } + label='Layer 2' + labelPlacement='bottom' + /> + this.handleButtonClick(2)} + /> + } + label='Layer 3' + labelPlacement='bottom' + /> + + + + ); + } +} + +export default Checkboxes;