diff --git a/pixi.lock b/pixi.lock index d0e75f9be..696d1166c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -265,7 +265,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtasn1-4.21.0-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtheora-1.1.1-h4ab18f5_1006.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtool-2.5.4-h5888daf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libunistring-0.9.10-h7f98852_0.tar.bz2 @@ -447,7 +447,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zziplib-0.13.69-he45264a_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.7.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda @@ -463,7 +463,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-argcomplete-0.3.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-bash-0.5.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-cd-0.1.1-pyhd8ed1ab_1.conda @@ -1257,7 +1257,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsystemd0-257.13-hf9559e3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtasn1-4.21.0-he30d5cf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtheora-1.1.1-h68df207_1006.conda - - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.2-hdb009f0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtool-2.5.4-h5ad3122_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libudev1-257.13-hf9559e3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libunistring-0.9.10-hf897c2e_0.tar.bz2 @@ -1435,7 +1435,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.2-hdc9db2a_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zziplib-0.13.69-h650d8d0_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.7.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda @@ -1451,7 +1451,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-argcomplete-0.3.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-bash-0.5.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-cd-0.1.1-pyhd8ed1ab_1.conda @@ -2386,7 +2386,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtasn1-4.21.0-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtheora-1.1.1-h4ab18f5_1006.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtool-2.5.4-h5888daf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libunistring-0.9.10-h7f98852_0.tar.bz2 @@ -2568,7 +2568,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zziplib-0.13.69-he45264a_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.7.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda @@ -2584,7 +2584,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-argcomplete-0.3.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-bash-0.5.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-cd-0.1.1-pyhd8ed1ab_1.conda @@ -3382,7 +3382,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsystemd0-257.13-hf9559e3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtasn1-4.21.0-he30d5cf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtheora-1.1.1-h68df207_1006.conda - - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.2-hdb009f0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtool-2.5.4-h5ad3122_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libudev1-257.13-hf9559e3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libunistring-0.9.10-hf897c2e_0.tar.bz2 @@ -3560,7 +3560,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.2-hdc9db2a_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zziplib-0.13.69-h650d8d0_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.7.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda @@ -3576,7 +3576,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-argcomplete-0.3.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-bash-0.5.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-cd-0.1.1-pyhd8ed1ab_1.conda @@ -8235,27 +8235,27 @@ packages: - libtheora >=1.1.1,<1.2.0a0 size: 328924 timestamp: 1719667859099 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda - sha256: e5f8c38625aa6d567809733ae04bb71c161a42e44a9fa8227abe61fa5c60ebe0 - md5: cd5a90476766d53e901500df9215e927 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + sha256: b31346e1c01ab40a170e91147092ee8fd92b1dee3c66ee47ef025571c879b159 + md5: c1fcb4a88bc15a9f77ad8d27d7af1df9 depends: - __glibc >=2.17,<3.0.a0 - - lerc >=4.0.0,<5.0a0 + - lerc >=4.1.0,<5.0a0 - libdeflate >=1.25,<1.26.0a0 - libgcc >=14 - - libjpeg-turbo >=3.1.0,<4.0a0 - - liblzma >=5.8.1,<6.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 - libstdcxx >=14 - libwebp-base >=1.6.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 - zstd >=1.5.7,<1.6.0a0 license: HPND purls: [] run_exports: weak: - - libtiff >=4.7.1,<4.8.0a0 - size: 435273 - timestamp: 1762022005702 + - libtiff >=4.7.2,<4.8.0a0 + size: 452337 + timestamp: 1783084902636 - conda: https://conda.anaconda.org/conda-forge/linux-64/libtool-2.5.4-h5888daf_0.conda sha256: c8245c70ba5b075e0cd61f430afbda00b60931603ed4ea31ce89e7fe930e4e3d md5: 90697d80c181414aa3472199e136a04e @@ -9532,7 +9532,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/propcache?source=hash-mapping + - pkg:pypi/propcache?source=compressed-mapping run_exports: {} size: 51586 timestamp: 1780037816755 @@ -9915,7 +9915,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/librt?source=hash-mapping + - pkg:pypi/librt?source=compressed-mapping run_exports: {} size: 160728 timestamp: 1782847498143 @@ -15401,26 +15401,26 @@ packages: - libtheora >=1.1.1,<1.2.0a0 size: 335103 timestamp: 1719667812650 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda - sha256: 7ff79470db39e803e21b8185bc8f19c460666d5557b1378d1b1e857d929c6b39 - md5: 8c6fd84f9c87ac00636007c6131e457d +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.2-hdb009f0_0.conda + sha256: ef1006578ef7e3f7c420e89d87846213ceb3fdcef2626af2558cbede53d36839 + md5: 20166e2297c1cac346b544d5f6197440 depends: - - lerc >=4.0.0,<5.0a0 + - lerc >=4.1.0,<5.0a0 - libdeflate >=1.25,<1.26.0a0 - libgcc >=14 - - libjpeg-turbo >=3.1.0,<4.0a0 - - liblzma >=5.8.1,<6.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 - libstdcxx >=14 - libwebp-base >=1.6.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 - zstd >=1.5.7,<1.6.0a0 license: HPND purls: [] run_exports: weak: - - libtiff >=4.7.1,<4.8.0a0 - size: 488407 - timestamp: 1762022048105 + - libtiff >=4.7.2,<4.8.0a0 + size: 508982 + timestamp: 1783084925965 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtool-2.5.4-h5ad3122_0.conda sha256: 3ad0e71e0997ef6a245ed1906ec2e86af7549a108c35d25110e66a3a4dece20e md5: 0651f29a5ea482c71f8a74932f01e02f @@ -18510,18 +18510,18 @@ packages: - zziplib >=0.13.69,<0.14.0a0 size: 115725 timestamp: 1719242037690 -- conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.4.0-pyhd8ed1ab_0.conda - sha256: 0e5e34179a52e0f3aa3c92904bd326de1d1cd74c6fe3bd98f8b8b6889491c7e4 - md5: a46362fa67f5138d526715107be0ee32 +- conda: https://conda.anaconda.org/conda-forge/noarch/absl-py-2.5.0-pyhd8ed1ab_0.conda + sha256: 8b1aaa574e152570aed4b85d916f88a8dd92b69bb9409bd7fb2efc27659be1e6 + md5: 1ec5202407b4006470f9a21c32c4b90c depends: - python >=3.10 license: Apache-2.0 license_family: Apache purls: - - pkg:pypi/absl-py?source=hash-mapping + - pkg:pypi/absl-py?source=compressed-mapping run_exports: {} - size: 109955 - timestamp: 1769637168641 + size: 115188 + timestamp: 1783083260530 - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.7.1-pyhd8ed1ab_0.conda sha256: 5ef589e5fd3736439781a9d48e68ce1e723b7081a471c02169908198eba4f448 md5: e51e09bf3b91c62b7461a9f94e92c2c9 @@ -18718,20 +18718,19 @@ packages: run_exports: {} size: 58872 timestamp: 1775127203018 -- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda - sha256: c253a41cdf898b651a0786cbb76c6d5fc101d0dbbe719f93a124bc4fde5cdd6a - md5: 554304a07e581a85891b15e39ea9f268 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda + sha256: ccc4787f511964f9a1f2d2d2859c91c5d571fb60f7f09d4c4e092c9b7a94e671 + md5: 2c4bd6aeb90bb157456841c3270a0d92 depends: - __unix - python - python >=3.10 license: BSD-3-Clause - license_family: BSD purls: - pkg:pypi/click?source=compressed-mapping run_exports: {} - size: 104999 - timestamp: 1779900548735 + size: 107155 + timestamp: 1783085363526 - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-argcomplete-0.3.3-pyhd8ed1ab_1.conda sha256: 05ccb85cad9ca58be9dcb74225f6180a68907a6ab0c990e3940f4decc5bb2280 md5: bde6042a1b40a2d4021e1becbe8dd84f @@ -19073,7 +19072,7 @@ packages: license: BSD-2-Clause license_family: BSD purls: - - pkg:pypi/decorator?source=hash-mapping + - pkg:pypi/decorator?source=compressed-mapping run_exports: {} size: 16102 timestamp: 1779115228886 @@ -20377,7 +20376,7 @@ packages: license: MPL-2.0 license_family: OTHER purls: - - pkg:pypi/pytest-rerunfailures?source=hash-mapping + - pkg:pypi/pytest-rerunfailures?source=compressed-mapping run_exports: {} size: 22855 timestamp: 1782920468250 diff --git a/src/bitbots_behavior/bitbots_blackboard/CMakeLists.txt b/src/bitbots_behavior/bitbots_blackboard/CMakeLists.txt index 9ae643437..1a94473cb 100644 --- a/src/bitbots_behavior/bitbots_blackboard/CMakeLists.txt +++ b/src/bitbots_behavior/bitbots_blackboard/CMakeLists.txt @@ -12,6 +12,9 @@ if(BUILD_TESTING) ament_mypy(CONFIG_FILE "${CMAKE_CURRENT_LIST_DIR}/mypy.ini") endif() +install(DIRECTORY scripts/ USE_SOURCE_PERMISSIONS + DESTINATION lib/${PROJECT_NAME}) + ament_python_install_package(${PROJECT_NAME}) ament_package() diff --git a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/body_blackboard.py b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/body_blackboard.py index d7c636104..695713593 100644 --- a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/body_blackboard.py +++ b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/body_blackboard.py @@ -8,6 +8,7 @@ from bitbots_blackboard.capsules.kick_capsule import KickCapsule from bitbots_blackboard.capsules.misc_capsule import MiscCapsule from bitbots_blackboard.capsules.pathfinding_capsule import PathfindingCapsule +from bitbots_blackboard.capsules.positioning_capsule import PositioningCapsule from bitbots_blackboard.capsules.team_data_capsule import TeamDataCapsule from bitbots_blackboard.capsules.world_model_capsule import WorldModelCapsule @@ -34,6 +35,7 @@ def __init__(self, node: Node, tf_buffer: tf2.BufferInterface): self.costmap = CostmapCapsule(self.node, self) self.pathfinding = PathfindingCapsule(self.node, self) self.team_data = TeamDataCapsule(self.node, self) + self.positioning = PositioningCapsule(self.node, self) self.capsules = [ self.misc, @@ -44,6 +46,7 @@ def __init__(self, node: Node, tf_buffer: tf2.BufferInterface): self.costmap, self.pathfinding, self.team_data, + self.positioning, ] def clear_cache(self): diff --git a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/animation_capsule.py b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/animation_capsule.py index 22dd79a30..e9edf9938 100644 --- a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/animation_capsule.py +++ b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/animation_capsule.py @@ -20,7 +20,6 @@ def __init__(self, node, blackboard): self.goalie_falling_left_animation: str = self._node.get_parameter("Animations.Goalie.fallLeft").value self.goalie_falling_center_animation: str = self._node.get_parameter("Animations.Goalie.fallCenter").value self.cheering_animation: str = self._node.get_parameter("Animations.Misc.cheering").value - self.init_animation: str = self._node.get_parameter("Animations.Misc.init").value self.animation_client = ActionClient( self._node, PlayAnimation, "animation", callback_group=ReentrantCallbackGroup() diff --git a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/game_status_capsule.py b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/game_status_capsule.py index 740f04e2c..c1ccd441e 100644 --- a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/game_status_capsule.py +++ b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/game_status_capsule.py @@ -23,11 +23,15 @@ def __init__(self, node, blackboard=None): self.free_kick_kickoff_team: bool | None = None self.game_controller_stop: bool = False self.last_timestep_whistle_detected: Time | None = None + self.last_timestep_in_set: float = 0.0 # publish stopped msg for hcm self.stop_pub = node.create_publisher(Bool, "game_controller/stop_msg", 1) # last kicking team self.last_kicking_team: int | None = None + def set_set_timestep(self): + self.last_timestep_in_set = self._node.get_clock().now().nanoseconds / 1e9 + def get_main_state(self) -> int: # Init, ready, set, playing, finished return self.gamestate.main_state @@ -67,6 +71,9 @@ def get_rival_score(self) -> int: def get_seconds_since_own_goal(self) -> float: return self._node.get_clock().now().nanoseconds / 1e9 - self.last_goal_from_us_time + + def get_seconds_since_kickoff(self) -> float: + return self._node.get_clock().now().nanoseconds / 1e9 - self.last_timestep_in_set def get_seconds_since_any_goal(self) -> float: return self._node.get_clock().now().nanoseconds / 1e9 - self.last_goal_time @@ -103,6 +110,9 @@ def received_gamestate(self) -> bool: def get_team_id(self) -> int: return self.team_id + def get_own_id(self) -> int: + return self.own_id + def gamestate_callback(self, gamestate_msg: GameState) -> None: if self.gamestate.penalized and not gamestate_msg.penalized: self.unpenalized_time = self._node.get_clock().now().nanoseconds / 1e9 diff --git a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/positioning_capsule.py b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/positioning_capsule.py new file mode 100644 index 000000000..4b71c4954 --- /dev/null +++ b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/positioning_capsule.py @@ -0,0 +1,540 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import TypedDict + +import numpy as np +from bitbots_utils.utils import get_parameters_from_other_node +from numpy.typing import NDArray +from scipy.optimize import linear_sum_assignment + +from bitbots_blackboard.capsules import AbstractBlackboardCapsule, cached_capsule_function + + +class RobotAssignment(TypedDict): + role: str + goal_pose: NDArray[np.float64] + + +class Role(StrEnum): + STRIKER = "striker" + GOALIE = "goalie" + DEFENDER = "defender" + SUPPORTER = "supporter" + + DEFENDER_0 = "defender_0" + DEFENDER_1 = "defender_1" + DEFENDER_2 = "defender_2" + DEFENDER_3 = "defender_3" + DEFENDER_4 = "defender_4" + DEFENDER_5 = "defender_5" + DEFENDER_6 = "defender_6" + + +@dataclass +class Field: + """Field geometry. Populated from the parameter blackboard in __init__.""" + + length: float = 9.0 # x extent (own goal at -length/2, opp goal at +length/2) + width: float = 6.0 # y extent + goal_width: float = 1.5 # goal mouth + margin: float = 0.3 # keep field players this far inside the touchlines + + +@dataclass +class Params: + """ + A single pure function `compute_formation(ball, field, n_players, params)` maps a + ball position to positions for every non-... well, *every* role (goalie, defenders, + supporter, striker), and a small matplotlib GUI to click a ball and watch the result. + + Core idea + --------- + Everything keys off two vectors derived from the ball B and our goal centre G: + + to_ball = normalize(B - G) # axis pointing from our goal out to the ball + perp = (-to_ball.y, to_ball.x) # perpendicular to that axis + + Defenders sit ON the axis (at a controlled depth from goal) and spread ALONG perp. + As the ball moves the axis rotates, so the same construction continuously morphs + from a horizontal defensive line (ball far/central) into a goal-line wall beside + the goalie (ball close & frontal). No special-casing -> automatically smooth. + + A short pairwise-repulsion pass at the end enforces a minimum separation; it is + also what shoves the defenders sideways into clean flanking slots when they would + otherwise pile onto the goalie -> the "wall next to the goalie" emerges for free. + """ + + # goalie + d_g: float = 0.55 # how far the goalie comes out of the goal (dist from goal centre) + # defenders + alpha: float = 0.42 # defender depth as a fraction of |ball-goal| (push-up factor) + depth_bias: float = 0.0 # extra defender depth: + = further forward, - = further back + D_min: float = 0.9 # min defender depth from goal (never tuck behind this) + D_max: float = 3.8 # max defender depth from goal (high-line cap) + dz: float = 0.45 # keep defenders at least this far ahead of the goalie + standoff: float = 1.0 # keep defenders at least this far (goal-side) of the ball + gap: float = 1.7 # lateral spacing between adjacent defenders (ball at/beyond the centre circle) + gap_close: float = 0.6 # lateral spacing when the ball is at our goal (defenders tuck in) + def_side: float = 0.9 # lateral offset for a lone defender (so it's not on the axis) + # supporter + include_supporter: bool = False # if False, no supporter is assigned; its slot becomes an extra defender + f: float = 1.6 # how far in front of the ball (toward opp goal) the supporter sits + supp_side: float = 1.2 # supporter lateral offset magnitude (auto-leans toward centre) + supp_max_x: float = 3.0 # supporter never goes past this x (keeps it out of the opp corner) + # striker + kick_offset: float = 0.25 # striker stands this far behind the ball (to push it forward) + post_margin: float = 0.45 # safety margin inside each goal post for a straight shot + back_dist: float = 0.21 # within this x of the opp goal & not aligned -> play back to our side + # separation + min_sep: float = 0.8 # no two robots closer than this + sep_iters: int = 8 + # kick lane: keep teammates out of the corridor in front of the ball + kick_clear: float = 0.7 # half-width of the cleared corridor + kick_range: float = 3.0 # how far in front of the ball the corridor extends + # opponent set play: keep all robots outside this radius + opp_set_play_clearance: float = 1.1 + + +class PositioningCapsule(AbstractBlackboardCapsule): + """Decides and communicates the optimal positions, based on the game situation.""" + + def __init__(self, node, blackboard): + super().__init__(node, blackboard) + + parameters = get_parameters_from_other_node( + self._node, + "/parameter_blackboard", + [ + "field.goal.width", + "field.markings.penalty_area.size.x", + "field.size.x", + "field.size.y", + ], + ) + + self._field = Field( + length=parameters["field.size.x"], + width=parameters["field.size.y"], + goal_width=parameters["field.goal.width"], + ) + self._params = Params() + # Whether the formation includes a supporter. Off by default; enabling it lets one + # field player push up as a supporter instead of being an extra defender. + self._params.include_supporter = bool(blackboard.config.get("formation_include_supporter", False)) + self._inner = InnerPositioningCapsule() + + # Cached capsule functions can not have parameters or side effects, + # so we have two separate functions for normal and set play formation assignment, + # that are cached separately. + @cached_capsule_function + def get_formation_assignment(self) -> dict[int, RobotAssignment]: + return self._get_formation_assignment(set_play=False) + + @cached_capsule_function + def get_set_play_formation_assignment(self) -> dict[int, RobotAssignment]: + return self._get_formation_assignment(set_play=True) + + def get_own_role(self) -> Role: + """Return the role of the robot with the own_id.""" + own_id = self._blackboard.gamestate.get_own_id() + assignment = self.get_formation_assignment() + return Role(assignment[own_id]["role"]) + + def _get_formation_assignment(self, set_play=False) -> dict[int, RobotAssignment]: + ball_pose = self._blackboard.world_model.get_best_ball_point_stamped() + ball = np.array([ball_pose.point.x, ball_pose.point.y]) + robot_poses = self._blackboard.team_data.get_robot_poses() + passive_robot_ids = self._blackboard.team_data.get_id_of_passive_player() + self._node.get_logger().info(f"Length of robot_poses: {len(robot_poses)}") + + formation = self._inner._compute_formation( + ball, self._field, len(robot_poses), self._params, opp_set_play=set_play + ) + new_items = list(formation.items()) + ordered_jerseys = sorted(robot_poses.keys()) + old_poses = [robot_poses[j] for j in ordered_jerseys] + # `_match_assignment` identifies robots by their index into `old_poses`, not by + # jersey/robot id, so translate every passive robot id we know about into its index + passive_indices = [ordered_jerseys.index(rid) for rid in passive_robot_ids if rid in ordered_jerseys] + pairs = self._inner._match_assignment(ordered_jerseys, old_poses, new_items, ball, passive_indices) + return {ordered_jerseys[old_idx]: {"role": role, "goal_pose": new_pose} for old_idx, new_pose, role in pairs} + + +class InnerPositioningCapsule: + """Pure & deterministic formation computation, independent of ROS and the rest of the system.""" + + # --------------------------------------------------------------------------- # + # Helpers + # --------------------------------------------------------------------------- # + + @staticmethod + def _normalize(v: NDArray[np.float64], fallback: NDArray[np.float64] | None = None) -> NDArray[np.float64]: + if fallback is None: + fallback = np.array([1.0, 0.0]) + n = np.linalg.norm(v) + return v / n if n > 1e-9 else fallback.copy() + + @staticmethod + def _face(frm: NDArray[np.float64], to: NDArray[np.float64], fallback: float = 0.0) -> float: + """Heading (rad) to look from `frm` toward `to`.""" + d = np.asarray(to) - np.asarray(frm) + return float(np.arctan2(d[1], d[0])) if np.linalg.norm(d) > 1e-9 else fallback + + @staticmethod + def _smoothstep(x: float, lo: float, hi: float) -> float: + """C1 ramp from 0 (x<=lo) to 1 (x>=hi).""" + if hi <= lo: + return float(x >= hi) + t = np.clip((x - lo) / (hi - lo), 0.0, 1.0) + return float(t * t * (3 - 2 * t)) + + @staticmethod + def _angle_diff(a: float, b: float) -> float: + """Smallest absolute angle between two headings (rad), in [0, pi].""" + return float(abs((a - b + np.pi) % (2 * np.pi) - np.pi)) + + @staticmethod + def _clamp_field(p: NDArray[np.float64], fld: Field, margin: float | None = None) -> NDArray[np.float64]: + """Clamp a point to the playable rectangle (continuous / C0).""" + m = fld.margin if margin is None else margin + return np.array( + [ + np.clip(p[0], -fld.length / 2 + m, fld.length / 2 - m), + np.clip(p[1], -fld.width / 2 + m, fld.width / 2 - m), + ] + ) + + # --------------------------------------------------------------------------- # + # Role allocation + # --------------------------------------------------------------------------- # + + @staticmethod + def _allocate_roles( + n: int, + ball: NDArray[np.float64] | None = None, + field: Field | None = None, + include_supporter: bool = True, + ) -> list[Role]: + """Keep-priority as the count drops: striker > goalie > 1st defender > supporter + > 2nd defender (and any further players are extra defenders). + + Equivalently, removing players one at a time drops: a defender, then the + supporter, then the other defender, then the goalie. + + Short-handed (n < 5) with the ball in our own defensive third, the supporter + is reassigned as an extra defender (note: this is a discrete role switch). + + When `include_supporter` is False, no supporter is allocated at all; its slot + becomes an extra defender instead. + """ + n_def = 0 + has_support = False + if n >= 3: + n_def += 1 # first defender + if n >= 4: + has_support = True # supporter + n_def += max(n - 4, 0) # everyone past 4 is another defender + + if has_support and n < 5 and ball is not None and field is not None: + own_third = -field.length / 2 + field.length / 3.0 + if ball[0] < own_third: + has_support, n_def = False, n_def + 1 + + # supporter disabled by config: reassign its slot as an extra defender + if has_support and not include_supporter: + has_support, n_def = False, n_def + 1 + + roles = [] + if n >= 1: + roles.append(Role.STRIKER) + if n >= 2: + roles.append(Role.GOALIE) + roles += [Role(f"{Role.DEFENDER}_{i}") for i in range(n_def)] + if has_support: + roles.append(Role.SUPPORTER) + return roles + + # --------------------------------------------------------------------------- # + # Separation / kick-lane clearance + # --------------------------------------------------------------------------- # + + def _separate( + self, + positions: dict[Role, NDArray[np.float64]], + field: Field, + params: Params, + ball: NDArray[np.float64] | None = None, + aim: NDArray[np.float64] | None = None, + clear_radius: float | None = None, + ) -> None: + """In-place relaxation: pairwise min-sep + clearing the striker's kick lane. + + Only the striker is a fixed anchor (it must reach the ball); everyone else, + goalie included, gives way around it. In normal play the goalie is far from + all teammates so it never moves; it only shifts off its line in the degenerate + case where the ball sits right in the goal mouth. Continuous in the inputs, + so small ball moves -> small position moves. + + `clear_radius` (opponent set play) is re-enforced at the *start* of every + iteration, so the pairwise pass that follows always gets the last word and + can re-separate robots that the clearance push just bunched together. A + final clearance pass after the loop is the hard guarantee (it's a + referee-enforced rule), even though separation wins within the loop.""" + fixed = {Role.STRIKER} + names = list(positions.keys()) + sep = params.min_sep + # model the kick corridor as a row of fixed repulsors along the aim, each with + # keep-out radius kick_clear: radial pushes are smooth (no side-flip teleport) + lane_pts, perp = [], None + if aim is not None and ball is not None: + perp = np.array([-aim[1], aim[0]]) + step = max(params.kick_clear * 0.7, 0.3) + lane_pts = [np.asarray(ball) + s * aim for s in np.arange(step, params.kick_range + 1e-9, step)] + for _ in range(params.sep_iters): + # enforce set-play clearance first so this iteration's pairwise pass gets the + # last word - otherwise robots bunched by the clearance push in the final + # iteration would have no chance to be re-separated + if clear_radius is not None and ball is not None: + self._clear_ball(positions, ball, clear_radius, field) + disp = {n: np.zeros(2) for n in names} + for i in range(len(names)): + for j in range(i + 1, len(names)): + a, b = names[i], names[j] + diff = positions[a] - positions[b] + dist = np.linalg.norm(diff) + if dist < sep: + dirv = self._normalize(diff, np.array([0.0, 1.0])) if dist > 1e-6 else np.array([0.0, 1.0]) + overlap = sep - dist + a_fixed, b_fixed = a in fixed, b in fixed + if a_fixed and b_fixed: + continue + if a_fixed: + disp[b] -= overlap * dirv + elif b_fixed: + disp[a] += overlap * dirv + else: + disp[a] += 0.5 * overlap * dirv + disp[b] -= 0.5 * overlap * dirv + # push teammates out of the kick corridor in front of the ball + for n in names: + if n in fixed: + continue + for lp in lane_pts: + diff = positions[n] - lp + dist = np.linalg.norm(diff) + if dist < params.kick_clear: + dirv = self._normalize(diff, perp) # exactly on the line -> step sideways + disp[n] += (params.kick_clear - dist) * dirv + for n in names: + if n in fixed: + continue + positions[n] = self._clamp_field(positions[n] + disp[n], field) + # hard guarantee: the set-play clearance is a referee-enforced rule, so it always + # wins in the end, even though separation gets the last word during the loop above + if clear_radius is not None and ball is not None: + self._clear_ball(positions, ball, clear_radius, field) + + def _clear_ball( + self, positions: dict[Role, NDArray[np.float64]], ball: NDArray[np.float64], radius: float, field: Field + ) -> None: + """Push all robots to at least `radius` distance from `ball` (in-place).""" + ball = np.asarray(ball) + for name in positions: + diff = positions[name] - ball + dist = np.linalg.norm(diff) + if dist < radius: + dirv = self._normalize(diff, fallback=np.array([-1.0, 0.0])) + positions[name] = self._clamp_field(ball + radius * dirv, field) + + # --------------------------------------------------------------------------- # + # Assignment + # --------------------------------------------------------------------------- # + + def _match_assignment( + self, + player_ids: Sequence[int], + old_poses: Sequence[list[float] | NDArray[np.float64]], + new_items: list[tuple[str, NDArray[np.float64]]], + ball: NDArray[np.float64] | tuple[float, float], + passive_indices: list[int], + angle_w: float = 0.3, + striker_deadband: float = 0.2, + ) -> list[tuple[int, NDArray[np.float64], str]]: + """Assign physical robots (at `old_poses`) to the new target poses. + The striker target goes to the robot with the lowest player number among all + robots whose distance to the ball is within `striker_deadband` metres of the + robot closest to the ball. Grouping the near-equidistant robots and breaking + the tie on the player number keeps two robots from both deferring to each other + when their ball-distance estimates disagree by a few centimetres. The rest are + matched optimally (Hungarian) to minimize total cost = distance + angle_w * + heading difference. Returns a list of (old_idx, new_pose, new_role) where + old_idx is the index into `old_poses`. + `player_ids`: player number of each robot, aligned with `old_poses`. + `old_poses`: list of [x, y, theta]; `new_items`: list of (role, [x, y, theta]). + `passive_indices`: indices into `old_poses` of robots that should not be assigned + the striker role. Pass an empty list if there are no passive robots. If every + robot is passive, the striker slot must still be filled, so a passive robot is + assigned it anyway. + """ + assert len(passive_indices) <= len(new_items), "more passive robots reported than robots to assign" + assert len(player_ids) == len(old_poses), "player_ids must be aligned with old_poses" + old_poses = [np.asarray(p) for p in old_poses] + ball = np.asarray(ball) + n = len(old_poses) + # striker target -> chosen from the robots nearest the ball + s_j = next(j for j, (r, _) in enumerate(new_items) if r == Role.STRIKER) + # distance of every robot to the ball + ball_dists = [float(np.linalg.norm(old_poses[i][:2] - ball)) for i in range(n)] + # exclude passive robots from the striker choice, unless every robot is passive + eligible = [i for i in range(n) if i not in passive_indices] or list(range(n)) + # treat every eligible robot within the deadband of the closest one as equally + # close to the ball, then take the lowest player number as a deterministic + # tie-break so both robots agree on who becomes striker + closest_dist = min(ball_dists[i] for i in eligible) + group = [i for i in eligible if ball_dists[i] <= closest_dist + striker_deadband] + s_i = min(group, key=lambda i: player_ids[i]) + pairs = [(s_i, new_items[s_j][1], new_items[s_j][0])] + rem_i = [i for i in range(n) if i != s_i] + rem_j = [j for j in range(len(new_items)) if j != s_j] + if rem_i: + cost = np.empty((len(rem_i), len(rem_j))) + for a, i in enumerate(rem_i): + for b, j in enumerate(rem_j): + op, npose = old_poses[i], new_items[j][1] + cost[a, b] = np.linalg.norm(op[:2] - npose[:2]) + angle_w * self._angle_diff(op[2], npose[2]) + ri, ci = linear_sum_assignment(cost) + for a, b in zip(ri, ci): + i, j = rem_i[a], rem_j[b] + pairs.append((i, new_items[j][1], new_items[j][0])) + return pairs + + # --------------------------------------------------------------------------- # + # Formation computation + # --------------------------------------------------------------------------- # + + def _compute_formation( + self, + ball: NDArray[np.float64] | tuple[float, float], + field: Field, + n_players: int, + params: Params, + opp_set_play: bool = False, + ) -> dict[str, NDArray[np.float64]]: + """Map a ball position -> {role: np.array([x, y, yaw])}. Pure & deterministic. + + yaw is in radians, z-up ROS convention (counter-clockwise from +x). + To build a ROS PoseStamped use `quat_from_yaw(pose[2])` from + `bitbots_utils.transforms` which returns a geometry_msgs/Quaternion in + xyzw order (ROS/tf convention). Internally, transforms3d uses wxyz order; + `bitbots_utils.transforms` handles the conversion. + """ + b = np.asarray(ball) + goal = np.array([-field.length / 2.0, 0.0]) + opp = np.array([+field.length / 2.0, 0.0]) + + d = np.linalg.norm(b - goal) + to_ball = self._normalize(b - goal) # our-goal -> ball + perp = np.array([-to_ball[1], to_ball[0]]) + + roles = self._allocate_roles(n_players, b, field, include_supporter=params.include_supporter) + if opp_set_play and Role.SUPPORTER in roles: + n_def = sum(1 for r in roles if r.startswith(Role.DEFENDER + "_")) + roles[roles.index(Role.SUPPORTER)] = Role(f"{Role.DEFENDER}_{n_def}") + out = {} + head = {} # role -> heading (rad); filled lazily, completed after separation + kick_aim = None # striker's kick direction; used to clear the kick lane + + # --- striker: stands behind the ball opposite the smoothly-chosen kick aim --- # + if Role.STRIKER in roles: + if opp_set_play: + # park at the clearance boundary on the goal side, facing the ball + dir_to_goal = self._normalize(goal - b, fallback=np.array([-1.0, 0.0])) + out[Role.STRIKER] = self._clamp_field(b + params.opp_set_play_clearance * dir_to_goal, field) + # heading will be computed in the orientation pass (face ball) + else: + h = max(field.goal_width / 2 - params.post_margin, 0.0) # safe half-mouth + # aim at the goal, target clamped into the safe mouth: this gives a straight + # (+x) shot whenever the ball is aligned within the posts, angled otherwise + target = np.array([opp[0], np.clip(b[1], -h, h)]) + aim_goal = self._normalize(target - b) + # fallback: play back toward our side (field centre) + aim_back = self._normalize(np.array([0.0, 0.0]) - b, fallback=np.array([-1.0, 0.0])) + # blend to back-pass only when close to the opp goal AND not aligned + near_goal = self._smoothstep(b[0], opp[0] - params.back_dist - 1.0, opp[0] - params.back_dist) + not_aligned = self._smoothstep(abs(b[1]), h, h + 1.0) + w_back = near_goal * not_aligned + # rotate the aim around the ball at constant angular rate (shortest path) so + # the striker swings smoothly rather than whipping when the two aims oppose + a0 = np.arctan2(aim_goal[1], aim_goal[0]) + a1 = np.arctan2(aim_back[1], aim_back[0]) + da = (a1 - a0 + np.pi) % (2 * np.pi) - np.pi # shortest signed turn + ang = a0 + w_back * da + aim = np.array([np.cos(ang), np.sin(ang)]) + out[Role.STRIKER] = b - params.kick_offset * aim + head[Role.STRIKER] = ang # striker faces where it kicks + kick_aim = aim + + # --- goalie: on the ball->goal axis, hugging the goal, clamped to the mouth -- # + if Role.GOALIE in roles: + g = goal + params.d_g * to_ball + g = np.array( + [ + np.clip(g[0], -field.length / 2 + 0.05, -field.length / 2 + params.d_g), + np.clip(g[1], -field.goal_width / 2, field.goal_width / 2), + ] + ) + out[Role.GOALIE] = g + + # --- defenders: anchor on axis at push-up depth, spread along perp ---------- # + defender_roles = [r for r in roles if r.startswith(Role.DEFENDER + "_")] + m = len(defender_roles) + if m > 0: + depth = np.clip( + params.alpha * d + params.depth_bias, # push up + fwd/back bias + params.D_min, + params.D_max, + ) + depth = min(depth, max(d - params.standoff, 0.0)) # stay goal-side of the ball + depth = max(depth, params.d_g + params.dz) # stay ahead of the goalie + anchor = goal + depth * to_ball + if m == 1: + # a single defender: shade to the centre side so it doesn't sit on the + # striker<->goalie line (otherwise all three are collinear) + side_dir = -1.0 if b[1] >= 0 else 1.0 + offsets = [params.def_side * side_dir] + else: + # tighten the defender line as the ball nears our goal: full `gap` at the + # centre circle (and beyond), shrinking smoothly to `gap_close` at the goal + t = self._smoothstep(d, 0.0, field.length / 2.0) + gap = params.gap_close + t * (params.gap - params.gap_close) + offsets = [(k - (m - 1) / 2.0) * gap for k in range(m)] + for r, off in zip(defender_roles, offsets, strict=True): + out[r] = self._clamp_field(anchor + off * perp, field) + + # --- supporter: slightly in front of the ball, kept inside the field -------- # + if Role.SUPPORTER in roles: + # always sit to the centre side of the ball; hard swap so it is never + # directly in front of the striker, even when the ball is on the centre line + side_dir = -1.0 if b[1] >= 0 else 1.0 # points toward y=0 (default: centre-ward) + sup = b + np.array([params.f, params.supp_side * side_dir]) + sup[0] = min(sup[0], params.supp_max_x) # don't drift into the opponent corner + out[Role.SUPPORTER] = self._clamp_field(sup, field) + + # --- min-separation repulsion + kick-lane clearance + set-play clearance -- # + self._separate( + out, field, params, b, kick_aim, clear_radius=params.opp_set_play_clearance if opp_set_play else None + ) + + # --- orientations (computed from the final positions) --------------------- # + for role, p in out.items(): + if role in head: + continue # striker already set (faces its kick aim) + if role == Role.SUPPORTER: + # face the bisector of (toward ball, toward opp goal): "watch both" + bis = self._normalize(b - p) + self._normalize(opp - p) + head[role] = self._face(np.zeros(2), bis, fallback=self._face(p, opp)) + else: + head[role] = self._face(p, b) # goalie + defenders face the ball + + return {role: np.array([p[0], p[1], head[role]]) for role, p in out.items()} diff --git a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/team_data_capsule.py b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/team_data_capsule.py index 8eda26d52..b9886333f 100644 --- a/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/team_data_capsule.py +++ b/src/bitbots_behavior/bitbots_blackboard/bitbots_blackboard/capsules/team_data_capsule.py @@ -1,3 +1,4 @@ +import math from typing import Literal, Optional import numpy as np @@ -188,6 +189,39 @@ def get_active_teammate_poses(self, count_goalies: bool = False) -> list[Pose]: poses.append(data.robot_position.pose) return poses + def get_id_of_passive_player(self) -> list[int]: + """Returns the robot ids of all robots (including this one) that are currently marked passive.""" + passive_robot_ids = [] + data: TeamData + for data in self.team_data.values(): + if self.is_valid(data) and (data.strategy.action is Strategy.ACTION_PASSIVE): + passive_robot_ids.append(data.robot_id) + if self.strategy.action is Strategy.ACTION_PASSIVE: + passive_robot_ids.append(self._blackboard.gamestate.get_own_id()) + return passive_robot_ids + + def quaternion_to_yaw(self, q) -> float: + """Extract yaw (theta) from a quaternion.""" + return math.atan2(2.0 * (q.w * q.z + q.x * q.y), 1.0 - 2.0 * (q.y * q.y + q.z * q.z)) + + def get_robot_poses(self, include_own: bool = True) -> dict[int, list[float]]: + """Returns a mapping of jersey_number -> [x, y, theta] for all active robots.""" + robot_poses = {} + data: TeamData + for data in self.team_data.values(): + if self.is_valid(data): + pose = data.robot_position.pose + robot_poses[data.robot_id] = [ + pose.position.x, + pose.position.y, + self.quaternion_to_yaw(pose.orientation), + ] + if include_own: + robot_poses[self._blackboard.gamestate.get_own_id()] = list( + self._blackboard.world_model.get_current_position() + ) + return robot_poses + def get_number_of_active_field_players(self, count_goalie: bool = False) -> int: def is_not_goalie(team_data: TeamData) -> bool: return team_data.strategy.role != Strategy.ROLE_GOALIE diff --git a/src/bitbots_behavior/bitbots_blackboard/scripts/debug_positioning.py b/src/bitbots_behavior/bitbots_blackboard/scripts/debug_positioning.py new file mode 100755 index 000000000..c16f95d40 --- /dev/null +++ b/src/bitbots_behavior/bitbots_blackboard/scripts/debug_positioning.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Debug GUI for InnerPositioningCapsule. + +Exercises the exact capsule code without starting the full stack. +Click the field to move the ball; use the sliders to tweak params. +""" + +from typing import TypedDict + +import numpy as np +from bitbots_blackboard.capsules.positioning_capsule import Field, InnerPositioningCapsule, Params + +_inner = InnerPositioningCapsule() + + +class State(TypedDict): + ball: tuple[float, float] + n: int + robots: list[np.ndarray] + + +def run_gui(): + import matplotlib.pyplot as plt + from matplotlib.patches import Circle, Rectangle + from matplotlib.widgets import CheckButtons, Slider + + fld = Field() + params = Params() + # `robots` holds the *persistent* physical robot positions (index = robot identity), + # updated in place every frame via `_match_assignment` - unlike the formation targets + # (which are recomputed from scratch each frame), this gives "robot i" a stable + # identity across ball moves, so marking one passive keeps referring to the same robot. + state: State = {"ball": (1.0, 0.5), "n": 5, "robots": []} + + colors = {"goalie": "#e6b800", "striker": "#d62728", "supporter": "#2ca02c"} + + fig, ax = plt.subplots(figsize=(9, 9)) + plt.subplots_adjust(left=0.08, right=0.97, top=0.98, bottom=0.50) + + def _ax(b): + return plt.axes((0.18, b, 0.72, 0.015)) + + s_n = Slider(_ax(0.470), "players", 1, 8, valinit=state["n"], valstep=1) + s_sep = Slider(_ax(0.442), "min_sep", 0.3, 2.0, valinit=params.min_sep) + s_alpha = Slider(_ax(0.414), "push α", 0.1, 0.8, valinit=params.alpha) + s_dbias = Slider(_ax(0.386), "def fwd/back", -2.0, 3.0, valinit=params.depth_bias) + s_dside = Slider(_ax(0.358), "def side", 0.0, 2.0, valinit=params.def_side) + s_gap = Slider(_ax(0.330), "def gap", 0.5, 2.0, valinit=params.gap) + s_gapc = Slider(_ax(0.302), "def gap @goal", 0.2, 2.0, valinit=params.gap_close) + s_f = Slider(_ax(0.274), "supp lead", 0.0, 3.0, valinit=params.f) + s_side = Slider(_ax(0.246), "supp side", 0.0, 2.5, valinit=params.supp_side) + s_smax = Slider(_ax(0.218), "supp max x", 0.0, 4.2, valinit=params.supp_max_x) + s_pmarg = Slider(_ax(0.190), "post margin", 0.0, 1.3, valinit=params.post_margin) + s_back = Slider(_ax(0.162), "back dist", 0.0, 3.0, valinit=params.back_dist) + s_kclr = Slider(_ax(0.134), "kick clear", 0.0, 1.5, valinit=params.kick_clear) + s_gout = Slider(_ax(0.106), "goalie out", 0.2, 2.0, valinit=params.d_g) + check_sp = CheckButtons(plt.axes((0.18, 0.078, 0.20, 0.022)), ["set play"], [False]) + check_supp = CheckButtons(plt.axes((0.42, 0.078, 0.20, 0.022)), ["supporter"], [params.include_supporter]) + s_spcl = Slider(_ax(0.050), "set play clearance", 0.1, 2.0, valinit=params.opp_set_play_clearance) + # one checkbox per possible robot identity (0..max players - 1); checkboxes beyond the + # current player count are simply ignored. CheckButtons (unlike TextBox) doesn't hook + # resize_event, so it doesn't hit the matplotlib bug where TextBox crashes on any + # window resize (ResizeEvent has no .inaxes, but TextBox._resize assumes it does). + max_players = 8 + ax_passive = plt.axes((0.915, 0.05, 0.07, 0.40)) # right of the sliders, below the plot + ax_passive.set_title("passive\nrobot", fontsize=9) + check_passive = CheckButtons(ax_passive, [str(i) for i in range(max_players)], [False] * max_players) + + def get_passive_indices(n_robots): + return [i for i, checked in enumerate(check_passive.get_status()) if checked and i < n_robots] + + def draw(): + ax.clear() + ax.add_patch( + Rectangle((-fld.length / 2, -fld.width / 2), fld.length, fld.width, fill=False, color="white", lw=2) + ) + ax.axvline(0, color="white", lw=1) + ax.add_patch(Circle((0, 0), 0.75, fill=False, color="white", lw=1)) + for sgn in (-1, 1): + ax.plot( + [sgn * fld.length / 2] * 2, + [-fld.goal_width / 2, fld.goal_width / 2], + color="#4da6ff" if sgn < 0 else "#ff9999", + lw=6, + ) + ax.set_facecolor("#2e7d32") + + params.min_sep, params.alpha, params.gap, params.f = s_sep.val, s_alpha.val, s_gap.val, s_f.val + params.gap_close = s_gapc.val + params.depth_bias, params.supp_side, params.d_g = s_dbias.val, s_side.val, s_gout.val + params.supp_max_x, params.def_side = s_smax.val, s_dside.val + params.post_margin, params.back_dist = s_pmarg.val, s_back.val + params.kick_clear = s_kclr.val + opp_set_play = check_sp.get_status()[0] + params.include_supporter = check_supp.get_status()[0] + params.opp_set_play_clearance = s_spcl.val + n = int(s_n.val) + + if opp_set_play: + ax.add_patch( + Circle( + state["ball"], + params.opp_set_play_clearance, + fill=False, + color="yellow", + lw=1.5, + ls="--", + zorder=2, + alpha=0.7, + ) + ) + + form = _inner._compute_formation(state["ball"], fld, n, params, opp_set_play=opp_set_play) + new_items = list(form.items()) + + # (re)seed robot identities from scratch whenever the player count changes, since + # there's no sensible way to carry old identities over a different-sized roster + if state["robots"] is None or len(state["robots"]) != len(new_items): + state["robots"] = [pose for _role, pose in new_items] + + passive_idxs = get_passive_indices(len(state["robots"])) + robots = state["robots"] + # assign each persistent robot (by index) to a role target for this frame + assignments = _inner._match_assignment(list(range(len(robots))), robots, new_items, state["ball"], passive_idxs) + + # ball + ax.add_patch(Circle(state["ball"], 0.12, color="white", ec="black", zorder=5)) + + # striker kick lane + aim arrow + if "striker" in form: + th = form["striker"][2] + aim = np.array([np.cos(th), np.sin(th)]) + perp = np.array([-aim[1], aim[0]]) + b = np.asarray(state["ball"]) + far = b + params.kick_range * aim + corner = perp * params.kick_clear + lane = np.array([b + corner, far + corner, far - corner, b - corner]) + ax.add_patch(plt.Polygon(lane, closed=True, color="white", alpha=0.10, zorder=1)) + ax.arrow( + *state["ball"], + *(1.4 * aim), + color="white", + width=0.02, + head_width=0.18, + length_includes_head=True, + zorder=8, + alpha=0.9, + ) + + # draw each robot at its assigned position, colored by its current role. The robot's + # identity (its index) and its passive 'x' stay glued to the same marker even when + # its role - and therefore its color - changes from frame to frame. + new_robots = list(robots) + for old_idx, new_pose, role in assignments: + old_pose = robots[old_idx] + p, th = new_pose[:2], new_pose[2] + c = colors.get(role.split("_")[0], "#1f77b4") + # dashed trail from where this robot was last frame; fixed high-contrast white + # (role colors like the green defender/supporter vanish against the pitch), with + # a hollow ring marking the start so the direction of travel is clear + ax.plot( + [old_pose[0], p[0]], [old_pose[1], p[1]], ls=(0, (4, 2)), color="white", lw=1.7, alpha=0.95, zorder=3 + ) + ax.add_patch(Circle(tuple(old_pose[:2]), 0.07, fill=False, ec="white", lw=1.2, alpha=0.8, zorder=3)) + ax.add_patch(Circle(tuple(p), params.min_sep / 2, color=c, alpha=0.18, zorder=2)) + ax.add_patch(Circle(tuple(p), 0.16, color=c, ec="black", zorder=6)) + ax.plot( + [p[0], p[0] + 0.45 * np.cos(th)], + [p[1], p[1] + 0.45 * np.sin(th)], + color="black", + lw=2.5, + zorder=7, + solid_capstyle="round", + ) + short = role.replace("defender_", "D").replace("supporter", "supp") + ax.annotate(f"{old_idx}:{short}", (p[0], p[1] - 0.30), color="white", fontsize=8, ha="center", zorder=8) + if old_idx in passive_idxs: + ax.plot(p[0], p[1], marker="x", color="black", markersize=13, mew=2.5, zorder=9) + new_robots[old_idx] = new_pose + state["robots"] = new_robots + + ax.set_xlim(-fld.length / 2 - 0.5, fld.length / 2 + 0.5) + ax.set_ylim(-fld.width / 2 - 0.5, fld.width / 2 + 0.5) + ax.set_aspect("equal") + ax.set_title( + "click to move the ball · label = robot#:role · dotted trail = movement since last frame · " + "'x' = passive (never assigned striker)", + color="black", + ) + fig.canvas.draw_idle() + + def on_click(event): + if event.inaxes is ax and event.xdata is not None: + state["ball"] = (event.xdata, event.ydata) + draw() + + for s in ( + s_n, + s_sep, + s_alpha, + s_dbias, + s_dside, + s_gap, + s_gapc, + s_f, + s_side, + s_smax, + s_pmarg, + s_back, + s_kclr, + s_gout, + s_spcl, + ): + s.on_changed(lambda _v: draw()) + check_sp.on_clicked(lambda _label: draw()) + check_supp.on_clicked(lambda _label: draw()) + check_passive.on_clicked(lambda _label: draw()) + fig.canvas.mpl_connect("button_press_event", on_click) + draw() + plt.show() + + +def main(): + run_gui() + + +if __name__ == "__main__": + main() diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_block_position.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_block_position.py deleted file mode 100644 index c2f91fc90..000000000 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_block_position.py +++ /dev/null @@ -1,170 +0,0 @@ -import math - -import numpy as np -from bitbots_blackboard.body_blackboard import BodyBlackboard -from bitbots_utils.transforms import quat_from_yaw -from dynamic_stack_decider.abstract_action_element import AbstractActionElement -from tf2_geometry_msgs import PoseStamped - - -class GoToBlockPosition(AbstractActionElement): - blackboard: BodyBlackboard - - def __init__(self, blackboard, dsd, parameters): - super().__init__(blackboard, dsd, parameters) - self.block_position_goal_offset = self.blackboard.config["block_position_goal_offset"] - self.block_radius = self.blackboard.config["block_radius_robot"] - self.left_goalpost_position = [ - -self.blackboard.world_model.field_length / 2, - self.blackboard.world_model.goal_width / 2, - ] # position of left goalpost - self.right_goalpost_position = [ - -self.blackboard.world_model.field_length / 2, - -self.blackboard.world_model.goal_width / 2, - ] # position of right goalpost - - def perform(self, reevaluate=False): - # The block position should be a position between the ball and the center of the goal - # and always in front of the goal - - # y - # ^ ______________________ - # | M | | | O - # | Y |_ -x, y | x, y _| P - # | G | | | | | P - # 0 + O | | ( ) | | G - # | A |_| | |_| O - # | L | -x,-y | x,-y | A - # | |__________|__________| L - # | - # +------------------+--------------> x - # 0 - ball_position = self.blackboard.world_model.get_ball_position_xy() - - ball_to_line_distance = ball_position[0] - self.left_goalpost_position[0] - - opening_angle = self._calc_opening_angle( - ball_to_line_distance, ball_position - ) # angle of ball to both goalposts - angle_bisector = opening_angle / 2 # of the angle between the goalpost - - goalie_pos = self._get_robot_pose(angle_bisector, ball_to_line_distance, ball_position) - - goalie_pos = self._cut_goalie_pos(goalie_pos, ball_position) - - pose_msg = PoseStamped() - pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg() - pose_msg.header.frame_id = self.blackboard.map_frame - - pose_msg.pose.position.x = float(goalie_pos[0]) - pose_msg.pose.position.y = float(goalie_pos[1]) - - yaw = self._calc_turn_to_ball_angle(ball_position, goalie_pos) - pose_msg.pose.orientation = quat_from_yaw(yaw) - - self.blackboard.pathfinding.publish(pose_msg) - - def _calc_opening_angle(self, ball_to_line: float, ball_pos: tuple) -> float: - """ - Calculates the opening angle of the ball to both goalposts. - With it we can get the angle bisector, in which we place the robot. - Args: - ball_to_line: distance of the ball to our goal line - ball_pos: ball position in world koordinate system - - Returns: - float: opening angle - """ - ball_angle_left = np.arctan2( - ball_pos[1] - self.left_goalpost_position[1], ball_to_line - ) # angle of ball to left goalpost - ball_angle_right = np.arctan2( - ball_pos[1] - self.right_goalpost_position[1], ball_to_line - ) # angle of ball to right goalpost - opening_angle_ball = ball_angle_right - ball_angle_left # Subtract to get the opening angle - return opening_angle_ball - - def _get_robot_pose( - self, angle_bisector: float, ball_to_line_distance: float, ball_pos: tuple[float, float] - ) -> tuple[float, float]: - """ - Calculates the position where the robot should be to block the ball. - The position is the place where the circle of the robot blocking radius is touching both lines - from the ball to the goalposts. So the goal is completely guarded. - - Args: - angle_bisector: bisector angle of the ball with goalposts - ball_to_line_distance: distance of the ball to the goal line - ball_pos: ball position in world koordinate system - - Returns: - tuple: goalie_position - """ - left_goalpost_to_ball = math.dist(self.left_goalpost_position, ball_pos) - right_goalpost_to_ball = math.dist(self.right_goalpost_position, ball_pos) - - wanted_distance_robot_to_ball = self.block_radius / np.sin(angle_bisector) # ball obstruction distance of robot - angle_of_left_goalpost_to_ball = np.arccos(ball_to_line_distance / left_goalpost_to_ball) - angle_of_right_goalpost_to_ball = np.arccos(ball_to_line_distance / right_goalpost_to_ball) - - if ball_pos[1] > self.blackboard.world_model.goal_width / 2: - angle_to_bisector = angle_of_left_goalpost_to_ball + angle_bisector # here left goalpost angle used - goalie_x = ball_pos[0] - wanted_distance_robot_to_ball * np.cos(angle_to_bisector) - goalie_y = ball_pos[1] - wanted_distance_robot_to_ball * np.sin(angle_to_bisector) - elif ball_pos[1] < -self.blackboard.world_model.goal_width / 2: - angle_to_bisector = angle_of_right_goalpost_to_ball + angle_bisector # here right goalpost angle used - goalie_x = ball_pos[0] - wanted_distance_robot_to_ball * np.cos(angle_to_bisector) - goalie_y = ball_pos[1] + wanted_distance_robot_to_ball * np.sin(angle_to_bisector) - else: - angle_to_bisector = angle_of_left_goalpost_to_ball - angle_bisector # here right goalpost angle used - goalie_x = ball_pos[0] - wanted_distance_robot_to_ball * np.cos(angle_to_bisector) - goalie_y = ball_pos[1] + wanted_distance_robot_to_ball * np.sin(angle_to_bisector) - # calculate the angle, the robot needs to turn to look at the ball - return (goalie_x, goalie_y) - - def _cut_goalie_pos(self, goalie_pos: tuple[float, float], ball_pos: tuple[float, float]) -> tuple[float, float]: - """ - Cut the goalie position if he is behind the goal or wants to leave the designated - goal area. - - Args: - goalie_pos: a tuple which contains [goalie_y, goalie_x] position - ball_pos: a tuple which contains [ball_y, ball_x] position - - Returns: - tuple: goalie_position - """ - goalie_pos_x, goalie_pos_y = goalie_pos - # cut if goalie is behind the goal happens when the ball is too close to the corner - if goalie_pos_x < -self.blackboard.world_model.field_length / 2: - goalie_pos_x = -self.blackboard.world_model.field_length / 2 + self.block_position_goal_offset - # instead put goalie to goalpost position - if ball_pos[1] > 0: - goalie_pos_y = self.blackboard.world_model.goal_width / 2 - else: - goalie_pos_y = -self.blackboard.world_model.goal_width / 2 - # cut so goalie does not leave goalkeeper area - goalie_pos_x = np.clip( - goalie_pos_x, - -self.blackboard.world_model.field_length / 2, - -self.blackboard.world_model.field_length / 2 - + self.blackboard.world_model.penalty_area_size_x - - self.block_radius, - ) - return (goalie_pos_x, goalie_pos_y) - - def _calc_turn_to_ball_angle(self, ball_pos: tuple[float, float], goalie_pos: tuple[float, float]) -> float: - """ - Calculates the angle the robot needs to turn to look at the ball. - - Args: - ball_pos: a tuple which contains [ball_y, ball_x] position - goalie_pos: a tuple which contains [goalie_y, goalie_x] position - - Returns: - float: angle - """ - robot_to_ball_x = ball_pos[0] - goalie_pos[0] - robot_to_ball_y = ball_pos[1] - goalie_pos[1] - angle = np.arctan2(robot_to_ball_y, robot_to_ball_x) - return angle diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_corner_kick_position.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_corner_kick_position.py deleted file mode 100644 index 6682b9272..000000000 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_corner_kick_position.py +++ /dev/null @@ -1,91 +0,0 @@ -import math - -from bitbots_blackboard.body_blackboard import BodyBlackboard -from dynamic_stack_decider.abstract_action_element import AbstractActionElement -from geometry_msgs.msg import Quaternion -from tf2_geometry_msgs import PoseStamped -from tf_transformations import quaternion_from_euler - - -class GoToCornerKickPosition(AbstractActionElement): - blackboard: BodyBlackboard - - def __init__(self, blackboard, dsd, parameters): - super().__init__(blackboard, dsd, parameters) - - # optional parameter which goes into the block position at a certain distance to the ball - self.mode = parameters.get("mode", None) - if self.mode is None or self.mode not in ("striker", "supporter", "others"): - self.blackboard.node.get_logger().error("mode for corner kick not specified") - raise ValueError("mode for corner kick not specified") - - def perform(self, reevaluate=False): - # The defense position should be a position between the ball and the own goal. - - # y - # ^ ______________________ - # | M | | | O - # | Y |_ -x, y | x, y _| P - # | G | | | | | P - # 0 + O | | ( ) | | G - # | A |_| | |_| O - # | L | -x,-y | x,-y | A - # | |__________|__________| L - # | - # +------------------+--------------> x - # 0 - - ball_position = self.blackboard.world_model.get_ball_position_xy() - field_length = self.blackboard.world_model.field_length - field_width = self.blackboard.world_model.field_width - - pose_msg = PoseStamped() - pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg() - pose_msg.header.frame_id = self.blackboard.map_frame - - # decide if the corner is on the left or right side of our goal - if ball_position[1] > 0: - # on the side of the field where y is positive - sign = 1 - else: - sign = -1 - - if self.mode == "striker": - # position relative to the corner - x_to_corner = 0.5 - y_to_corner = 0.5 - x = field_length / 2 + x_to_corner - y = sign * (field_width / 2 + y_to_corner) - yaw = sign * (5 * math.tau / 8) - elif self.mode == "supporter": - # position relative to the corner - x_to_corner = -1.5 - y_to_corner = -1.5 - x = field_length / 2 + x_to_corner - y = sign * (field_width / 2 + y_to_corner) - yaw = 0 - elif self.mode == "others": - # use fixed position rather than standing between ball and goal since there is the goal post - # x dependent on role position - if self.blackboard.team_data.role == "defense": - # close to post - x_from_goal_line = 0.5 - else: - # offense players further away based on their position number - x_from_goal_line = 1.5 + self.blackboard.misc.position_number - x = x_from_goal_line - (field_length / 2) - # 1 m away on the side line - y = sign * ((field_width / 2) - 1) - yaw = sign * (math.tau / 4) - - pose_msg.pose.position.x = x - pose_msg.pose.position.y = y - quaternion = Quaternion() - x, y, z, w = quaternion_from_euler(0, 0, yaw) - quaternion.x = x - quaternion.y = y - quaternion.z = z - quaternion.w = w - pose_msg.pose.orientation = quaternion - - self.blackboard.pathfinding.publish(pose_msg) diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_defense_position.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_defense_position.py deleted file mode 100644 index a84743a06..000000000 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_defense_position.py +++ /dev/null @@ -1,90 +0,0 @@ -import math - -import numpy as np -from bitbots_blackboard.body_blackboard import BodyBlackboard -from dynamic_stack_decider.abstract_action_element import AbstractActionElement -from geometry_msgs.msg import Quaternion -from tf2_geometry_msgs import PoseStamped -from tf_transformations import quaternion_from_euler - - -class GoToDefensePosition(AbstractActionElement): - blackboard: BodyBlackboard - - def __init__(self, blackboard, dsd, parameters): - super().__init__(blackboard, dsd, parameters) - - # Also apply offset from the ready positions to the defense positions - role_positions = self.blackboard.config["role_positions"] - try: - generalized_role_position = role_positions[self.blackboard.team_data.role]["active"][ - str(self.blackboard.misc.position_number) - ] - except KeyError as e: - raise KeyError(f"Role position for {self.blackboard.team_data.role} not specified in config") from e - - self.y_offset = generalized_role_position[1] * self.blackboard.world_model.field_width / 2 - # optional parameter which goes into the block position at a certain distance to the ball - self.mode = parameters.get("mode", None) - - def perform(self, reevaluate=False): - # The defense position should be a position between the ball and the own goal. - - # y - # ^ ______________________ - # | M | | | O - # | Y |_ -x, y | x, y _| P - # | G | | | | | P - # 0 + O | | ( ) | | G - # | A |_| | |_| O - # | L | -x,-y | x,-y | A - # | |__________|__________| L - # | - # +------------------+--------------> x - # 0 - - goal_position = (-self.blackboard.world_model.field_length / 2, 0) # position of the own goal - ball_position = self.blackboard.world_model.get_ball_position_xy() - robot_x, robot_y, _ = np.array(self.blackboard.world_model.get_current_position()) - our_pose = [robot_x, robot_y] - - pose_msg = PoseStamped() - pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg() - pose_msg.header.frame_id = self.blackboard.map_frame - - if self.mode == "freekick_first": - vector_ball_to_goal = np.array(goal_position) - np.array(ball_position) - # pos between ball and goal but 1.5m away from ball - defense_pos = vector_ball_to_goal / np.linalg.norm(vector_ball_to_goal) * 1.5 + np.array(ball_position) - pose_msg.pose.position.x = defense_pos[0] - pose_msg.pose.position.y = defense_pos[1] - yaw = math.atan(-vector_ball_to_goal[1] / -vector_ball_to_goal[0]) - x, y, z, w = quaternion_from_euler(0, 0, yaw) - pose_msg.pose.orientation = Quaternion(x=x, y=y, z=z, w=w) - elif self.mode == "freekick_second": - vector_ball_to_goal = np.array(goal_position) - np.array(ball_position) - # pos between ball and goal but 1.5m away from ball and 1m to the side which is closer to us - defense_pos = vector_ball_to_goal / np.linalg.norm(vector_ball_to_goal) * 1.5 + np.array(ball_position) - yaw = math.atan(-vector_ball_to_goal[1] / -vector_ball_to_goal[0]) - - # decide on side that is closer - pos_1 = np.array([defense_pos[0] + math.sin(yaw) * 1, defense_pos[1] + math.cos(yaw) * 1]) - pos_2 = np.array([defense_pos[0] - math.sin(yaw) * 1, defense_pos[1] - math.cos(yaw) * 1]) - distance_1 = np.linalg.norm(our_pose - pos_1) - distance_2 = np.linalg.norm(our_pose - pos_2) - - if distance_1 < distance_2: - pose_msg.pose.position.x = pos_1[0] - pose_msg.pose.position.y = pos_1[1] - else: - pose_msg.pose.position.x = pos_2[0] - pose_msg.pose.position.y = pos_2[1] - x, y, z, w = quaternion_from_euler(0, 0, yaw) - pose_msg.pose.orientation = Quaternion(x=x, y=y, z=z, w=w) - else: - # center point between ball and own goal - pose_msg.pose.position.x = (goal_position[0] + ball_position[0]) / 2 - pose_msg.pose.position.y = ball_position[1] / 2 + self.y_offset - pose_msg.pose.orientation.w = 1.0 - - self.blackboard.pathfinding.publish(pose_msg) diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_formation.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_formation.py new file mode 100644 index 000000000..6c24879ae --- /dev/null +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_formation.py @@ -0,0 +1,89 @@ +import math + +import numpy as np +from bitbots_blackboard.body_blackboard import BodyBlackboard +from dynamic_stack_decider.abstract_action_element import AbstractActionElement +from ros2_numpy import numpify +from tf2_geometry_msgs import PoseStamped +from tf_transformations import euler_from_quaternion, quaternion_from_euler + + +class GoToFormationPosition(AbstractActionElement): + blackboard: BodyBlackboard + + def __init__(self, blackboard, dsd, parameters): + super().__init__(blackboard, dsd, parameters) + + self.set_play = parameters.get("set_play", False) + + self.stand = parameters.get("stand", False) + if self.stand: + self.on_position_threshold = parameters["enter_position"] + self.off_position_threshold = parameters["leave_position"] + self.on_orientation_threshold = parameters["enter_orientation"] + self.off_orientation_threshold = parameters["leave_orientation"] + else: + self.on_position_threshold = None + self.off_position_threshold = None + self.on_orientation_threshold = None + self.off_orientation_threshold = None + self.standing_on_target = False + + def perform(self, reevaluate=False): + if self.set_play: + optimal_positioning = self.blackboard.positioning.get_set_play_formation_assignment() + else: + optimal_positioning = self.blackboard.positioning.get_formation_assignment() + + own_target = optimal_positioning[self.blackboard.gamestate.get_own_id()] + goal_pose = own_target["goal_pose"] # [x, y, orientation] + + current_pose = self.blackboard.world_model.get_current_position_pose_stamped() + + current_orientation = euler_from_quaternion(numpify(current_pose.pose.orientation)) + angle_to_goal_orientation = abs(math.remainder(current_orientation[2] - goal_pose[2], math.tau)) + + distance = np.linalg.norm(goal_pose[:2] - numpify(current_pose.pose.position)[:2]) + + self.publish_debug_data("current_orientation", current_orientation[2]) + self.publish_debug_data("goal_orientation", goal_pose[2]) + self.publish_debug_data("angle_to_goal_orientation", angle_to_goal_orientation) + self.publish_debug_data("distance", distance) + assert not self.stand or self.on_position_threshold < self.off_position_threshold, ( + "on_position_threshold must be smaller than off_position_threshold" + ) + assert not self.stand or self.on_orientation_threshold < self.off_orientation_threshold, ( + "on_orientation_threshold must be smaller than off_orientation_threshold" + ) + + if ( + self.stand + and distance < self.on_position_threshold + and angle_to_goal_orientation < self.on_orientation_threshold + ): + self.standing_on_target = True + + if self.standing_on_target and ( + distance < self.off_position_threshold and angle_to_goal_orientation < self.off_orientation_threshold + ): + # Cancel the path planning if it is running + self.blackboard.pathfinding.cancel_goal() + # need to keep publishing this since path planning publishes a few more messages + self.blackboard.pathfinding.stop_walk() + else: + self.standing_on_target = False + + pose_msg = PoseStamped() + pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg() + pose_msg.header.frame_id = self.blackboard.map_frame + + goal_pose = own_target["goal_pose"] + pose_msg.pose.position.x = goal_pose[0] + pose_msg.pose.position.y = goal_pose[1] + quat = quaternion_from_euler(0, 0, goal_pose[2]) + pose_msg.pose.orientation.x = quat[0] + pose_msg.pose.orientation.y = quat[1] + pose_msg.pose.orientation.z = quat[2] + pose_msg.pose.orientation.w = quat[3] + + self.blackboard.pathfinding.publish(pose_msg) diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_pass_position.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_pass_position.py deleted file mode 100644 index 3a018489a..000000000 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_pass_position.py +++ /dev/null @@ -1,78 +0,0 @@ -from bitbots_blackboard.body_blackboard import BodyBlackboard -from bitbots_utils.transforms import quat_from_yaw -from dynamic_stack_decider.abstract_action_element import AbstractActionElement -from tf2_geometry_msgs import PoseStamped - - -class AbstractGoToPassPosition(AbstractActionElement): - blackboard: BodyBlackboard - - def __init__(self, blackboard, dsd, accept, parameters): - super().__init__(blackboard, dsd, parameters) - self.max_x = self.blackboard.config["supporter_max_x"] - self.pass_pos_x = self.blackboard.config["pass_position_x"] - self.pass_pos_y = self.blackboard.config["pass_position_y"] - self.accept = accept - self.blocking = parameters.get("blocking", True) - - def perform(self, reevaluate=False): - # get ball pos - ball_pos = self.blackboard.world_model.get_ball_position_xy() - our_pose = self.blackboard.world_model.get_current_position() - - # compute goal - goal_x = ball_pos[0] - if self.accept: - goal_x += self.pass_pos_x - - # Limit the x position, so that we are not running into the enemy goal - goal_x = min(self.max_x, goal_x) - - # Calculate the two possible y positions for the pass position - goal_y_options = [ball_pos[1] + self.pass_pos_y, ball_pos[1] - self.pass_pos_y] - - # Filter out out of field positions - def is_in_field(y): - field_width = self.blackboard.world_model.field_width - return -field_width / 2 <= y <= field_width / 2 - - goal_y_options_filtered = list(filter(is_in_field, goal_y_options)) - - # Fallback: choose the y position that is closest to us if both positions are out of the field (should not happen really, but just in case) - if len(goal_y_options_filtered) == 0: - goal_y_options_filtered = goal_y_options - - def distance_to_our_pose(y): - return abs(y - our_pose[1]) - - goal_y = min(goal_y_options_filtered, key=distance_to_our_pose) - goal_yaw = 0.0 - - pose_msg = PoseStamped() - pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg() - pose_msg.header.frame_id = self.blackboard.map_frame - pose_msg.pose.position.x = float(goal_x) - pose_msg.pose.position.y = float(goal_y) - pose_msg.pose.orientation = quat_from_yaw(goal_yaw) - self.blackboard.pathfinding.publish(pose_msg) - - if not self.blocking: - self.pop() - - -class GoToPassPreparePosition(AbstractGoToPassPosition): - """ - Go to a position 1m left or right from the ball (whichever is closer) as preparation for a pass - """ - - def __init__(self, blackboard, dsd, parameters): - super().__init__(blackboard, dsd, False, parameters) - - -class GoToPassAcceptPosition(AbstractGoToPassPosition): - """ - Go to a position forward of the ball to accept a pass from another robot. - """ - - def __init__(self, blackboard, dsd, parameters): - super().__init__(blackboard, dsd, True, parameters) diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_role_position.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_role_position.py index b7e31edf4..2ae984c59 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_role_position.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/go_to_role_position.py @@ -1,6 +1,7 @@ from bitbots_blackboard.body_blackboard import BodyBlackboard from dynamic_stack_decider.abstract_action_element import AbstractActionElement from tf2_geometry_msgs import PoseStamped +from tf_transformations import euler_from_quaternion, quaternion_from_euler class GoToRolePosition(AbstractActionElement): @@ -23,9 +24,15 @@ def __init__(self, blackboard, dsd, parameters): # Adapt position to field size # TODO know where map frame is located + try: + rotation = generalized_role_position[2] + except IndexError: + rotation = 0.0 + self.role_position = [ generalized_role_position[0] * self.blackboard.world_model.field_length / 2, generalized_role_position[1] * self.blackboard.world_model.field_width / 2, + rotation ] self.blocking = parameters.get("blocking", True) @@ -37,7 +44,12 @@ def perform(self, reevaluate=False): pose_msg.pose.position.x = self.role_position[0] pose_msg.pose.position.y = self.role_position[1] - pose_msg.pose.orientation.w = 1.0 + self.blackboard.node.get_logger().info(f"Going to role position: {self.role_position[0]}, {self.role_position[1]}, {self.role_position[2]}") + x,y,z,w = quaternion_from_euler(0, 0, self.role_position[2]) + pose_msg.pose.orientation.x = x + pose_msg.pose.orientation.y = y + pose_msg.pose.orientation.z = z + pose_msg.pose.orientation.w = w self.blackboard.pathfinding.publish(pose_msg) diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/play_animation.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/play_animation.py index 53840f312..b2fb4e6c8 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/play_animation.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/play_animation.py @@ -85,16 +85,3 @@ class PlayAnimationThrow(AbstractPlayAnimation): def get_animation_name(self): self.blackboard.node.get_logger().info("PLAYING THROW-IN ANIMATION") return self.blackboard.animation.throw_animation - - -class PlayAnimationInit(AbstractPlayAnimation): - def get_animation_name(self): - return self.blackboard.animation.init_animation - - -class PlayAnimationInitInSim(PlayAnimationInit): - def perform(self, reevaluate=False): - if self.blackboard.in_sim: - return super().perform(reevaluate) - else: - return self.pop() diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/set_no_kick_off_or_throw_in_variable.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/set_no_kick_off_or_throw_in_variable.py index f4826b530..34e1eb648 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/set_no_kick_off_or_throw_in_variable.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/set_no_kick_off_or_throw_in_variable.py @@ -18,3 +18,20 @@ def __init__(self, blackboard, dsd, parameters): def perform(self, reevaluate=False): self.pop() + +class SetTimerKickoff(AbstractActionElement): + """ + Sets the no_second_ball_contact variable. + """ + + blackboard: BodyBlackboard + + def __init__(self, blackboard, dsd, parameters): + super().__init__(blackboard, dsd, parameters) + self.do_not_reevaluate() + self.blackboard = blackboard + + self.blackboard.gamestate.set_set_timestep() + + def perform(self, reevaluate=False): + self.pop() diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/walk.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/walk.py index 249271701..073ddf9dc 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/walk.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/actions/walk.py @@ -16,7 +16,25 @@ def perform(self, reevaluate=False): # Publish the walk command cmd_vel = Twist() - cmd_vel.linear.x = 0.8 + cmd_vel.linear.x = 0.4 + self.blackboard.pathfinding.direct_cmd_vel_pub.publish(cmd_vel) + else: + self.pop() + +class WalkBackward(AbstractActionElement): + def __init__(self, blackboard, dsd, parameters): + super().__init__(blackboard, dsd, parameters) + self.time = parameters.get("time", 0.5) + self.start_time = self.blackboard.node.get_clock().now() + + def perform(self, reevaluate=False): + if self.blackboard.node.get_clock().now() - self.start_time < Duration(seconds=self.time): + # Cancel the path planning if it is running + self.blackboard.pathfinding.cancel_goal() + + # Publish the walk command + cmd_vel = Twist() + cmd_vel.linear.x = -0.8 self.blackboard.pathfinding.direct_cmd_vel_pub.publish(cmd_vel) else: self.pop() diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/closest_to_ball.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/closest_to_ball.py index c3b7766b6..33c02777b 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/closest_to_ball.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/closest_to_ball.py @@ -40,27 +40,16 @@ def get_reevaluate(self): return True -class RankToBallNoGoalie(AbstractDecisionElement): +class AssignedRole(AbstractDecisionElement): blackboard: BodyBlackboard def __init__(self, blackboard, dsd, parameters): super().__init__(blackboard, dsd, parameters) def perform(self, reevaluate=False): - my_time_to_ball = self.blackboard.team_data.get_own_time_to_ball() - rank = self.blackboard.team_data.team_rank_to_ball(my_time_to_ball, count_goalies=False, use_time_to_ball=True) - self.publish_debug_data("time to ball", my_time_to_ball) - self.publish_debug_data("Rank to ball", rank) - if rank == 1: - return "FIRST" - elif rank == 2: - return "SECOND" - elif rank == 3: - return "THIRD" - else: - # emergency fall back if something goes wrong - self.blackboard.node.get_logger().warning("Rank to ball had some issues") - return "FIRST" + role = self.blackboard.positioning.get_own_role() + self.publish_debug_data("Role from positioning", role) + return role.upper() def get_reevaluate(self): return True diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/config_role.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/config_role.py index b3b32f6f7..74009dd73 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/config_role.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/config_role.py @@ -4,7 +4,7 @@ class ConfigRole(AbstractDecisionElement): """ - Decides what kind of behaviour the robot performs + Decides what kind of behavior the robot performs """ blackboard: BodyBlackboard diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/game_state_decider.py b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/game_state_decider.py index 76d38db49..71f120600 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/game_state_decider.py +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/decisions/game_state_decider.py @@ -44,3 +44,15 @@ def get_reevaluate(self): Game state can change during the game """ return True + +class WasKickoff(AbstractDecisionElement): + blackboard: BodyBlackboard + + def __init__(self, blackboard, dsd, parameters): + super().__init__(blackboard, dsd, parameters) + + def perform(self, reevaluate=False): + if self.blackboard.gamestate.get_seconds_since_kickoff() < 5.0: + return "YES" + else: + return "NO" diff --git a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/main.dsd b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/main.dsd index 47a3228b7..2d24ec568 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/main.dsd +++ b/src/bitbots_behavior/bitbots_body_behavior/bitbots_body_behavior/behavior_dsd/main.dsd @@ -18,7 +18,7 @@ $DoOnce @ChangeAction + action:waiting, @LookAtFieldFeatures, @Stand #GetWalkreadyAndLocalize -@ChangeAction + action:waiting + r:false, @PlayAnimationInitInSim + r:false, @LookAtFieldFeatures + r:false, @WalkInPlace +@ChangeAction + action:waiting + r:false, @LookAtFieldFeatures + r:false, @WalkInPlace #Dribble @ChangeAction + action:kicking, @CancelPathplanning, @LookAtFront, @DribbleForward @@ -34,11 +34,11 @@ $DoOnce $KickOrDribble + threshold_upfield:2.0 + threshold_downfield:0.5 KICK --> $DoOnce NOT_DONE --> @ChangeAction + action:going_to_ball, @LookAtFieldFeatures, @GoToBall + target:rl_kick + blocking:false - DONE --> $ReachedAndAlignedToPathPlanningGoalPosition + threshold:%rl_kick_approach_tolerance_pos + orientation_threshold:%rl_kick_approach_tolerance_deg + latch:true + DONE --> $ReachedAndAlignedToPathPlanningGoalPosition + threshold:%rl_kick_approach_tolerance_pos + orientation_threshold:%rl_kick_approach_tolerance_deg NO --> @ChangeAction + action:going_to_ball, @LookAtFieldFeatures, @GoToBall + target:rl_kick YES --> $DoOnce NOT_DONE --> @StoreBallMovementDetectionStartPosition - DONE --> @ChangeAction + action:kicking, @Stand + duration:0.1 + r:false, @LookAtBallPenalty + r:false, @RLKickTowardsGoal + strength:3.0 + r:false, @ForgetBall + DONE --> @ChangeAction + action:kicking, @Stand + duration:0.1 + r:false, @LookAtBallPenalty + r:false, @RLKickTowardsGoal + strength:3.0 + r:false, @ForgetBall + r:false DRIBBLE --> $AvoidBall // old behavior before kick when we want to kick the ball in NO --> $BallClose + distance:%ball_reapproach_dist + angle:%ball_reapproach_angle YES --> $BallKickArea @@ -60,31 +60,13 @@ $GoalScoreRecently ELSE --> @ChangeAction + action:positioning, @PlaySound + file:ole.wav, @LookAtFieldFeatures, @Stand + duration:%ready_wait_time, @AvoidBallActive, @GoToRolePosition NO --> @ChangeAction + action:positioning, @LookAtFieldFeatures, @Stand + duration:%ready_wait_time, @AvoidBallActive, @GoToRolePosition -#GoalieBehavior -$CountActiveRobotsWithoutGoalie - ZERO --> #KickWithAvoidance - ELSE --> $ClosestToBall - NO --> @ChangeAction + action:positioning, @AvoidBallActive, @LookAtFieldFeatures, @GoToBlockPosition - YES --> $BallInGoalieZone - YES --> #KickWithAvoidance - NO --> @ChangeAction + action:positioning, @AvoidBallActive, @LookAtFieldFeatures, @GoToBlockPosition - #SupporterRole -$PassStarted - YES --> $BallSeen - YES --> @TrackBall, @ChangeAction + action:positioning, @AvoidBallActive, @GoToPassAcceptPosition - NO --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @AvoidBallActive, @GoToPassAcceptPosition - NO --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @AvoidBallActive, @GoToPassPreparePosition +$BallSeen + YES --> $PassStarted + YES --> @TrackBall, @ChangeAction + action:positioning, @AvoidBallActive, @GoToFormationPosition + NO --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @AvoidBallActive, @GoToFormationPosition + stand:true + enter_position:%support_enter_position + leave_position:%support_leave_position + enter_orientation:%support_enter_orientation + leave_orientation:%support_leave_orientation + NO --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @AvoidBallActive, @GoToFormationPosition + stand:true + enter_position:%support_enter_position + leave_position:%support_leave_position + enter_orientation:%support_enter_orientation + leave_orientation:%support_leave_orientation -#StrikerRole -$GoalieHandlingBall - YES --> #SupporterRole - NO --> #KickWithAvoidance - -#DefensePositioning -$GoalieActive - YES --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @GoToDefensePosition - NO --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @GoToBlockPosition #PenaltyShootoutBehavior $SecondaryStateTeamDecider @@ -97,63 +79,41 @@ $SecondaryStateTeamDecider YES --> @TrackBall, @Stand NO --> @LookAtFieldFeatures, @Stand -#Placing -$DoOnce - NOT_DONE --> @ForgetBall, @LookAtFieldFeatures - DONE --> $ConfigRole - GOALIE --> @ChangeAction + action:positioning, @LookAtFieldFeatures, @GoToBlockPosition - ELSE --> $BallSeen - NO --> $SecondaryStateTeamDecider - OUR --> #SearchBall - ELSE --> @AvoidBallActive, @LookAtFieldFeatures, @WalkInPlace + duration:2, @GoToRelativePosition + x:1 + y:0 + t:0, @Stand - YES --> $RankToBallNoGoalie - FIRST --> @ChangeAction + action:positioning, @LookAtFieldFeatures, @AvoidBallActive, @GoToDefensePosition + mode:freekick_first - SECOND --> @ChangeAction + action:positioning, @LookAtFieldFeatures, @AvoidBallActive, @GoToDefensePosition + mode:freekick_second - THIRD --> @ChangeAction + action:positioning, @LookAtFieldFeatures, @AvoidBallActive, @GoToDefensePosition - #Init @Stand + duration:0.1 + r:false, @ChangeAction + action:waiting, @LookForward, @Stand #NormalBehavior $SecondBallTouchAllowed - NO --> $ConfigRole - GOALIE --> @SetNoSecondBallContactVariable + value:false + r:false, @LookAtFieldFeatures + r:false, @ChangeAction + action:passive + r:false, @AvoidBallActive + r:false, @GoToBlockPosition - ELSE --> @SetNoSecondBallContactVariable + value:false + r:false, @LookAtFieldFeatures + r:false, @ChangeAction + action:passive + r:false, @AvoidBallActive + r:false, @GoToDefensePosition + NO --> @SetNoSecondBallContactVariable + value:false + r:false, @LookAtFieldFeatures + r:false, @ChangeAction + action:passive + r:false, @AvoidBallActive + r:false, @GoToFormationPosition //defender variables YES --> $BallSeen NO --> $ConfigRole - GOALIE --> #RolePositionWithPause - ELSE --> #SearchBall - YES --> $KickOffTimeUp - NO_NORMAL --> #StandAndLook - NO_FREEKICK --> #Placing - YES --> $ConfigRole - GOALIE --> #GoalieBehavior - ELSE --> $CountActiveRobotsWithoutGoalie - ONE --> $RankToBallNoGoalie - FIRST --> #StrikerRole - SECOND --> #DefensePositioning - ELSE --> $RankToBallNoGoalie - FIRST --> #StrikerRole - SECOND --> #SupporterRole - THIRD --> #DefensePositioning - -#ThrowinGoalKickCornerkickBehavior + STRIKER --> #SearchBall + ELSE --> #RolePositionWithPause + YES --> $AssignedRole + STRIKER --> #KickWithAvoidance + SUPPORTER --> #SupporterRole + ELSE --> $BallInOwnPercent + p:40 + YES --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @GoToFormationPosition + NO --> @LookAtFieldFeatures, @ChangeAction + action:positioning, @GoToFormationPosition + stand:true + enter_position:%defender_enter_position + leave_position:%defender_leave_position + enter_orientation:%defender_enter_orientation + leave_orientation:%defender_leave_orientation + +#ActivateDoubleTouchProtection +@SetNoSecondBallContactVariable + value:true + r:false, @ForgetBallStartPosition + r:false + +#DefensiveSetPlay +@ChangeAction + action:positioning, @ForgetBall, @LookAtFieldFeatures, @GoToFormationPosition + set_play:true + stand:true + enter_position:%placing_enter_position + leave_position:%placing_leave_position + enter_orientation:%placing_enter_orientation + leave_orientation:%placing_leave_orientation + +#SetPlaySituation $DoOnce - NOT_DONE --> @SetNoSecondBallContactVariable + value:true, @ForgetBallStartPosition + r:false + NOT_DONE --> #ActivateDoubleTouchProtection DONE --> $SecondaryStateTeamDecider OUR --> #NormalBehavior - ELSE --> #Placing + ELSE --> #DefensiveSetPlay #PlayingBehavior $SecondaryStateDecider PENALTYSHOOT --> #PenaltyShootoutBehavior TIMEOUT --> #StandAndLook - THROW_IN --> #ThrowinGoalKickCornerkickBehavior - CORNER_KICK --> #ThrowinGoalKickCornerkickBehavior - GOAL_KICK --> #ThrowinGoalKickCornerkickBehavior - ELSE --> $SecondaryStateTeamDecider - OUR --> #NormalBehavior - ELSE --> #Placing + ELSE --> #SetPlaySituation NORMAL --> #NormalBehavior OVERTIME --> #NormalBehavior @@ -167,7 +127,7 @@ $IsPenalized NO --> $GameStateDecider INITIAL --> #Init READY --> $DoOnce - NOT_DONE --> @SetNoSecondBallContactVariable + value:true + r:false, @ForgetBallStartPosition + r:false + NOT_DONE --> #ActivateDoubleTouchProtection DONE --> $AnyGoalScoreRecently + time:50 YES --> #PositioningReady NO --> $DoOnce @@ -184,10 +144,18 @@ $IsPenalized OTHER --> $BallSeen YES --> @Stand + duration:0.1 + r:false, @DeactivateHCM + r:false, @LookForward + r:false, @TrackBall + r:false, @PlayAnimationGoalieArms + r:false, @Stand // goalie only needs to care about the ball NO --> @Stand + duration:0.1 + r:false, @DeactivateHCM + r:false, @LookForward + r:false, @LookAtFieldFeatures + r:false, @PlayAnimationGoalieArms + r:false, @Stand - ELSE --> #StandAndLook + ELSE --> $SecondaryStateTeamDecider + OUR --> @ChangeAction + action:waiting, @LookAtFieldFeatures, @Stand + duration:0.1, @SetTimerKickoff + ELSE --> #StandAndLook FINISHED --> $CurrentScore AHEAD --> @Stand + duration:0.5 + r:false, @PlaySound + file:fanfare.wav, @PlayAnimationCheering + r:false, @LookForward, @Stand ELSE --> #Init - PLAYING --> #PlayingBehavior + PLAYING --> $DoOnce //replace PlayingBehavior to fix + NOT_DONE --> $ConfigRole + OFFENSE --> $WasKickoff + YES --> @Stand + duration:8.0 + r:false, @WalkForward + time:2 + r:false, @ChangeAction + action:passive + r:false, @WalkBackward + time:2 + r:false, @Stand + duration:5.0 + NO --> #PlayingBehavior + ELSE --> #PlayingBehavior + DONE --> #PlayingBehavior STANDBY --> #StandAndLook STOPPED --> #DoNothing diff --git a/src/bitbots_behavior/bitbots_body_behavior/config/animations.yaml b/src/bitbots_behavior/bitbots_body_behavior/config/animations.yaml index fdfa1bf08..67b33cee9 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/config/animations.yaml +++ b/src/bitbots_behavior/bitbots_body_behavior/config/animations.yaml @@ -11,4 +11,3 @@ throw: "throw" Misc: cheering: "cheering" - init: "init_sim" diff --git a/src/bitbots_behavior/bitbots_body_behavior/config/body_behavior.yaml b/src/bitbots_behavior/bitbots_body_behavior/config/body_behavior.yaml index 173a0718a..25ad4b64f 100644 --- a/src/bitbots_behavior/bitbots_body_behavior/config/body_behavior.yaml +++ b/src/bitbots_behavior/bitbots_body_behavior/config/body_behavior.yaml @@ -3,6 +3,7 @@ body_behavior: # Data older than this is seen as non existent team_data_timeout: 2 + # The roles that are used in the initial role configuration. roles: - "goalie" - "offense" @@ -24,29 +25,48 @@ body_behavior: # To be useful for different field sizes, use values in [-1, 1] for x and y role_positions: - goalie: [ -0.7, 0.0 ] + goalie: [ -0.9, 0.0 ] defense: passive: # passive: opponent has kickoff, active: we have kickoff # position number 0 = center, 1 = left, 2 = right 0: [ -0.5, 0.0 ] - 1: [ -0.45, 0.5 ] - 2: [ -0.45, -0.5 ] + 1: [ -0.6, 0.2 ] + 2: [ -0.6, -0.2 ] active: 0: [ -0.5, 0.0 ] - 1: [ -0.45, 0.5 ] - 2: [ -0.45, -0.5 ] + 1: [ -0.6, 0.2 ] + 2: [ -0.6, -0.2 ] offense: passive: 0: [ -0.27, 0.0 ] 1: [ -0.2, 0.33 ] 2: [ -0.2, -0.33 ] active: - 0: [ -0.1, 0.0 ] + 0: [ 0.07, -0.04, 2.53] 1: [ -0.085, 0.33 ] 2: [ -0.085, -0.33 ] + # Whether the formation assignment includes a supporter role. + # If false, the supporter's slot is filled by an extra defender instead. + formation_include_supporter: false + + support_enter_position: 0.1 + support_leave_position: 0.3 + support_enter_orientation: 0.1 + support_leave_orientation: 0.3 + + defender_enter_position: 0.2 + defender_leave_position: 0.5 + defender_enter_orientation: 0.1 + defender_leave_orientation: 0.3 + + placing_enter_position: 0.05 + placing_leave_position: 0.2 + placing_enter_orientation: 0.05 + placing_leave_orientation: 0.1 + # Time to wait in ready state before moving to role position to give the localization time to converge. - ready_wait_time: 4.0 + ready_wait_time: 2.0 # The orientation threshold defining which range (in Degrees) is acceptable as aligned to the goal (in each direction) goal_alignment_orientation_threshold: 10.0 @@ -218,3 +238,11 @@ body_behavior: # factor by which the difference in starting and goal angle is weighted (only if not turning to ball i.e. <1m) time_to_ball_cost_start_to_goal_angle: 3.82 + + ############################# + # waiting for kick-off params + ############################# + + wait_after_kick_off_bool: true + wait_after_kick_off_time: 15 + wait_after_kick_off_effective_radius: 0.5 diff --git a/src/bitbots_motion/bitbots_hcm/bitbots_hcm/hcm_dsd/actions/play_animation.py b/src/bitbots_motion/bitbots_hcm/bitbots_hcm/hcm_dsd/actions/play_animation.py index d2678fd71..2002e35c3 100644 --- a/src/bitbots_motion/bitbots_hcm/bitbots_hcm/hcm_dsd/actions/play_animation.py +++ b/src/bitbots_motion/bitbots_hcm/bitbots_hcm/hcm_dsd/actions/play_animation.py @@ -152,11 +152,6 @@ def choose_animation(self): return self.blackboard.animation_name_stand_up_back -class PlayAnimationInit(AbstractPlayAnimation): - def choose_animation(self): - return self.blackboard.animation_name_init - - class PlayAnimationStartup(AbstractPlayAnimation): def choose_animation(self): return self.blackboard.animation_name_startup diff --git a/src/bitbots_robot/piplus_animations/animations/misc/init.json b/src/bitbots_robot/piplus_animations/animations/misc/init.json deleted file mode 100644 index 4b86810a5..000000000 --- a/src/bitbots_robot/piplus_animations/animations/misc/init.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "default_interpolator": "CatmullRomInterpolator", - "name": "init", - "keyframes": [ - { - "duration": 2.0, - "pause": 0.0, - "goals": { - "l_shoulder_pitch_joint": 0.0, - "l_shoulder_roll_joint": 0.0, - "l_upper_arm_joint": 0.0, - "l_elbow_joint": 0.0, - "r_shoulder_pitch_joint": 0.0, - "r_shoulder_roll_joint": 0.0, - "r_upper_arm_joint": 0.0, - "r_elbow_joint": 0.0, - "l_hip_pitch_joint": 0.0, - "l_hip_roll_joint": 0.0, - "l_thigh_joint": 0.0, - "l_calf_joint": 0.0, - "l_ankle_pitch_joint": 0.0, - "l_ankle_roll_joint": 0.0, - "r_hip_pitch_joint": 0.0, - "r_hip_roll_joint": 0.0, - "r_thigh_joint": 0.0, - "r_calf_joint": 0.0, - "r_ankle_pitch_joint": 0.0, - "r_ankle_roll_joint": 0.0, - "head_yaw_joint": 0.0, - "head_pitch_joint": 0.0 - } - } - ] -} diff --git a/src/bitbots_team_communication/bitbots_team_data_sim_rqt/bitbots_team_data_sim_rqt/team_data_ui.py b/src/bitbots_team_communication/bitbots_team_data_sim_rqt/bitbots_team_data_sim_rqt/team_data_ui.py index b72cd3405..d055c2c68 100755 --- a/src/bitbots_team_communication/bitbots_team_data_sim_rqt/bitbots_team_data_sim_rqt/team_data_ui.py +++ b/src/bitbots_team_communication/bitbots_team_data_sim_rqt/bitbots_team_data_sim_rqt/team_data_ui.py @@ -3,6 +3,7 @@ import sys from ament_index_python import get_package_share_directory +from geometry_msgs.msg import PoseWithCovariance from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QComboBox, @@ -31,7 +32,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setTitle("Robot") # Set maximum width - self.setMaximumWidth(350) + self.setMaximumWidth(450) self.setMinimumWidth(150) # Create layout self.main_layout = QVBoxLayout() @@ -45,6 +46,16 @@ def __init__(self, parent=None): self.main_layout.addWidget(self.state_box) self.time_to_position_box = TimeToPositionBox() self.main_layout.addWidget(self.time_to_position_box) + self.position_sliders_x = PositionCoordsX() + self.main_layout.addWidget(self.position_sliders_x) + self.position_sliders_y = PositionCoordsY() + self.main_layout.addWidget(self.position_sliders_y) + self.ball_position_sliders_y = BallPositionCoordsY() + self.main_layout.addWidget(self.ball_position_sliders_y) + self.ball_position_sliders_x = BallPositionCoordsX() + self.main_layout.addWidget(self.ball_position_sliders_x) + self.ball_covariance = BallCovariance() + self.main_layout.addWidget(self.ball_covariance) self.strategy_box = StrategyBox() self.main_layout.addWidget(self.strategy_box) self.publish_button = PublishButton() @@ -58,6 +69,11 @@ def id_spin_box_update(self): self.time_to_position_box.setEnabled(enabled) self.strategy_box.setEnabled(enabled) self.publish_button.setEnabled(enabled) + self.position_sliders_x.setEnabled(enabled) + self.position_sliders_y.setEnabled(enabled) + self.ball_position_sliders_x.setEnabled(enabled) + self.ball_position_sliders_y.setEnabled(enabled) + self.ball_covariance.setEnabled(enabled) def get_robot_id(self) -> int: return self.id_spin_box.value() @@ -121,6 +137,131 @@ def get_time(self) -> float: return float(self.time_spin_box.value()) +class PositionCoordsX(QGroupBox): + def __init__(self, parent=None): + super().__init__(parent) + # x position + self.setTitle("X position") + self.setEnabled(False) + # Create layout + self.main_layout = QHBoxLayout() + self.setLayout(self.main_layout) + # Create spin box + self.x_pos_spin_box = QSpinBox() + self.x_pos_spin_box.setRange(-550, 550) + self.main_layout.addWidget(self.x_pos_spin_box) + # Create slider + self.x_pos_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] + self.x_pos_slider.setRange(-550, 550) + self.main_layout.addWidget(self.x_pos_slider) + # Connect spin box and slider + self.x_pos_spin_box.valueChanged.connect(self.x_pos_slider.setValue) # type: ignore[attr-defined] + self.x_pos_slider.valueChanged.connect(self.x_pos_spin_box.setValue) # type: ignore[attr-defined] + + def get_x_position(self) -> float: + return float(self.x_pos_spin_box.value() / 100) + + +class PositionCoordsY(QGroupBox): + def __init__(self, parent=None): + super().__init__(parent) + # y position + self.setTitle("Y position") + self.setEnabled(False) + # Create layout + self.main_layout = QHBoxLayout() + self.setLayout(self.main_layout) + # Create spin box + self.y_pos_spin_box = QSpinBox() + self.y_pos_spin_box.setRange(-400, 400) + self.main_layout.addWidget(self.y_pos_spin_box) + # Create slider + self.y_pos_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] + self.y_pos_slider.setRange(-400, 400) + self.main_layout.addWidget(self.y_pos_slider) + # Connect spin box and slider + self.y_pos_spin_box.valueChanged.connect(self.y_pos_slider.setValue) # type: ignore[attr-defined] + self.y_pos_slider.valueChanged.connect(self.y_pos_spin_box.setValue) # type: ignore[attr-defined] + + def get_y_position(self) -> float: + return float(self.y_pos_spin_box.value() / 100) + + +class BallPositionCoordsX(QGroupBox): + def __init__(self, parent=None): + super().__init__(parent) + # x position + self.setTitle("Ball X position") + self.setEnabled(False) + # Create layout + self.main_layout = QHBoxLayout() + self.setLayout(self.main_layout) + # Create spin box + self.x_ball_pos_spin_box = QSpinBox() + self.x_ball_pos_spin_box.setRange(-550, 550) + self.main_layout.addWidget(self.x_ball_pos_spin_box) + # Create slider + self.x_ball_pos_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] + self.x_ball_pos_slider.setRange(-550, 550) + self.main_layout.addWidget(self.x_ball_pos_slider) + # Connect spin box and slider + self.x_ball_pos_spin_box.valueChanged.connect(self.x_ball_pos_slider.setValue) # type: ignore[attr-defined] + self.x_ball_pos_slider.valueChanged.connect(self.x_ball_pos_spin_box.setValue) # type: ignore[attr-defined] + + def get_x_ball_position(self) -> float: + return float(self.x_ball_pos_spin_box.value() / 100) + + +class BallPositionCoordsY(QGroupBox): + def __init__(self, parent=None): + super().__init__(parent) + # y position + self.setTitle("Y ball position") + self.setEnabled(False) + # Create layout + self.main_layout = QHBoxLayout() + self.setLayout(self.main_layout) + # Create spin box + self.y_ball_pos_spin_box = QSpinBox() + self.y_ball_pos_spin_box.setRange(-400, 400) + self.main_layout.addWidget(self.y_ball_pos_spin_box) + # Create slider + self.y_ball_pos_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] + self.y_ball_pos_slider.setRange(-400, 400) + self.main_layout.addWidget(self.y_ball_pos_slider) + # Connect spin box and slider + self.y_ball_pos_spin_box.valueChanged.connect(self.y_ball_pos_slider.setValue) # type: ignore[attr-defined] + self.y_ball_pos_slider.valueChanged.connect(self.y_ball_pos_spin_box.setValue) # type: ignore[attr-defined] + + def get_y_ball_position(self) -> float: + return float(self.y_ball_pos_spin_box.value() / 100) + + +class BallCovariance(QGroupBox): + def __init__(self, parent=None): + super().__init__(parent) + # y position + self.setTitle("Ball covariance in 0.1 sum") + self.setEnabled(False) + # Create layout + self.main_layout = QHBoxLayout() + self.setLayout(self.main_layout) + # Create spin box + self.ball_covariance_box = QSpinBox() + self.ball_covariance_box.setRange(-400, 400) + self.main_layout.addWidget(self.ball_covariance_box) + # Create slider + self.ball_covariance_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined] + self.ball_covariance_slider.setRange(-400, 400) + self.main_layout.addWidget(self.ball_covariance_slider) + # Connect spin box and slider + self.ball_covariance_box.valueChanged.connect(self.ball_covariance_slider.setValue) # type: ignore[attr-defined] + self.ball_covariance_slider.valueChanged.connect(self.ball_covariance_box.setValue) # type: ignore[attr-defined] + + def get_ball_covariance(self) -> float: + return float(self.ball_covariance_box.value() / 10) + + class StrategyBox(QGroupBox): _roles: dict[str, int] = { "Undefined": Strategy.ROLE_UNDEFINED, @@ -251,6 +392,48 @@ def timer_callback(self): if robot_widget.active(): team_data = TeamData() team_data.robot_id = robot_widget.get_robot_id() + pose_with_cov = PoseWithCovariance() + pose_with_cov.pose.position.x = robot_widget.position_sliders_x.get_x_position() + pose_with_cov.pose.position.y = robot_widget.position_sliders_y.get_y_position() + pose_with_cov.covariance = [ + robot_widget.ball_covariance.get_ball_covariance() / 2, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + robot_widget.ball_covariance.get_ball_covariance() / 2, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.01, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.01, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.01, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.05, + ] + team_data.robot_position = pose_with_cov team_data.state = robot_widget.state_box.get_state() team_data.time_to_position_at_ball = robot_widget.time_to_position_box.get_time() team_data.strategy.role = robot_widget.strategy_box.get_role() diff --git a/src/lib/soccer_vision_3d_rviz_markers/soccer_vision_3d_rviz_markers/visualizer.py b/src/lib/soccer_vision_3d_rviz_markers/soccer_vision_3d_rviz_markers/visualizer.py index fd7b5e546..79d6abb7c 100755 --- a/src/lib/soccer_vision_3d_rviz_markers/soccer_vision_3d_rviz_markers/visualizer.py +++ b/src/lib/soccer_vision_3d_rviz_markers/soccer_vision_3d_rviz_markers/visualizer.py @@ -14,6 +14,7 @@ # limitations under the License. import rclpy +from rclpy.experimental.events_executor import EventsExecutor from rclpy.node import Node from soccer_vision_3d_msgs.msg import ( @@ -89,14 +90,15 @@ def main(args=None): rclpy.init(args=args) node = SoccerVision3DMarkers() + + executor = EventsExecutor() + executor.add_node(node) + try: - rclpy.spin(node) + executor.spin() except KeyboardInterrupt: pass - node.destroy_node() - rclpy.shutdown() - if __name__ == '__main__': main()