diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 28e8a60db..d4d12c078 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-FROM ghcr.io/ubcsailbot/sailbot_workspace/dev:fix-building-pre-base
+FROM ghcr.io/ubcsailbot/sailbot_workspace/dev:moved-network-deps
# Copy configuration files (e.g., .vimrc) from config/ to the container's home directory
ARG USERNAME=ros
diff --git a/.devcontainer/base-dev/base-dev.Dockerfile b/.devcontainer/base-dev/base-dev.Dockerfile
index 3499485b3..22cb0cc18 100644
--- a/.devcontainer/base-dev/base-dev.Dockerfile
+++ b/.devcontainer/base-dev/base-dev.Dockerfile
@@ -194,14 +194,6 @@ RUN apt-get update \
&& rosdep init || echo "rosdep already initialized"
ENV DEBIAN_FRONTEND=
-# install base python3 dependencies
-RUN pip3 install \
- # from local pathfinding
- plotly \
- pyproj \
- flask \
- shapely
-
# root bash configuration
ENV ROS_WORKSPACE=/workspaces/sailbot_workspace
COPY update-bashrc.sh /sbin/update-bashrc
@@ -213,9 +205,6 @@ RUN chmod +x /sbin/update-bashrc \
# set timezone
ENV TZ="America/Vancouver"
-# customize ROS log format: https://docs.ros.org/en/humble/Concepts/About-Logging.html#environment-variables
-ENV RCUTILS_CONSOLE_OUTPUT_FORMAT="[{severity}] [{time}] [{name}:{line_number}]: {message}"
-
FROM base as local-base
# install virtual iridium dependencies
@@ -255,6 +244,17 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} /tmp/* /var/tmp/*
ENV DEBIAN_FRONTEND=
+# install rapidyaml for diagnostics
+ENV DEBIAN_FRONTEND=noninteractive
+RUN wget https://github.com/biojppm/rapidyaml/releases/download/v0.5.0/rapidyaml-0.5.0-src.tgz
+RUN tar -xzf rapidyaml-0.5.0-src.tgz && \
+ cd rapidyaml-0.5.0-src && \
+ cmake -S "." -B ./build/Release/ryml-build "-DCMAKE_INSTALL_PREFIX=/usr" -DCMAKE_BUILD_TYPE=Release && \
+ cmake --build ./build/Release/ryml-build --parallel --config Release && \
+ cmake --build ./build/Release/ryml-build --config Release --target install && \
+ rm -rf *rapidyaml*
+ENV DEBIAN_FRONTEND=
+
FROM local-base as ros-dev
# From https://github.com/athackst/dockerfiles/blob/32a872348af0ad25ec4a6e6184cb803357acb6ab/ros2/humble.Dockerfile
@@ -354,33 +354,11 @@ RUN apt-get update \
clangd \
clang-tidy \
cmake \
- googletest \
- libboost-all-dev \
- libprotobuf-dev \
- protobuf-compiler \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} /tmp/* /var/tmp/*
ENV DEBIAN_FRONTEND=
-# install rapidyaml for diagnostics
-ENV DEBIAN_FRONTEND=noninteractive
-RUN wget https://github.com/biojppm/rapidyaml/releases/download/v0.5.0/rapidyaml-0.5.0-src.tgz
-RUN tar -xzf rapidyaml-0.5.0-src.tgz && \
- cd rapidyaml-0.5.0-src && \
- cmake -S "." -B ./build/Release/ryml-build "-DCMAKE_INSTALL_PREFIX=/usr" -DCMAKE_BUILD_TYPE=Release && \
- cmake --build ./build/Release/ryml-build --parallel --config Release && \
- cmake --build ./build/Release/ryml-build --config Release --target install && \
- rm -rf *rapidyaml*
-ENV DEBIAN_FRONTEND=
-
-# install dev python3 dependencies
-RUN pip3 install \
- # to be able to run juypter notebooks
- ipykernel \
- # for integration_tests package
- types-PyYAML
-
# install other helpful apt packages
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
@@ -392,3 +370,8 @@ RUN apt-get update \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} /tmp/* /var/tmp/*
ENV DEBIAN_FRONTEND=
+
+# install dev python3 dependencies
+RUN pip3 install \
+ # for juypter notebooks
+ ipykernel
diff --git a/.devcontainer/base-dev/update-bashrc.sh b/.devcontainer/base-dev/update-bashrc.sh
index 36efa2dc4..0a08e0118 100644
--- a/.devcontainer/base-dev/update-bashrc.sh
+++ b/.devcontainer/base-dev/update-bashrc.sh
@@ -11,6 +11,8 @@ echo "" >> $HOME/.bashrc
echo "# set up ROS environment" >> $HOME/.bashrc
echo "source /usr/share/colcon_cd/function/colcon_cd.sh" >> $HOME/.bashrc
echo "export _colcon_cd_root=$ROS_WORKSPACE" >> $HOME/.bashrc
+echo "# customize ROS log format: https://docs.ros.org/en/humble/Concepts/About-Logging.html#environment-variables" >> $HOME/.bashrc
+echo "export RCUTILS_CONSOLE_OUTPUT_FORMAT='[{severity}] [{time}] [{name}:{line_number}]: {message}'" >> $HOME/.bashrc
echo "source /opt/ros/$ROS_DISTRO/setup.bash" >> $HOME/.bashrc
echo "if [ -f $ROS_WORKSPACE/install/local_setup.bash ]" >> $HOME/.bashrc
echo "then" >> $HOME/.bashrc
diff --git a/.devcontainer/docs/README.md b/.devcontainer/docs/README.md
index b135caac9..607a4c48c 100644
--- a/.devcontainer/docs/README.md
+++ b/.devcontainer/docs/README.md
@@ -1,6 +1,6 @@
# Docs Image
-Used for running [our docs site](https://github.com/UBCSailbot/docs).
+Used for running [our docs site](https://ubcsailbot.github.io/sailbot_workspace/main/).
## Features
diff --git a/.devcontainer/website/README.md b/.devcontainer/website/README.md
index cb8dc0ebc..d9778ef1e 100644
--- a/.devcontainer/website/README.md
+++ b/.devcontainer/website/README.md
@@ -1,6 +1,6 @@
# Website Image
-Used for running [our website](https://github.com/UBCSailbot/website).
+Used for running [our website](https://github.com/UBCSailbot/sailbot_workspace/tree/main/src/website).
## Features
diff --git a/.devcontainer/website/website.Dockerfile b/.devcontainer/website/website.Dockerfile
index 893136ada..0a19cdac8 100644
--- a/.devcontainer/website/website.Dockerfile
+++ b/.devcontainer/website/website.Dockerfile
@@ -1,7 +1,4 @@
-# Copied from https://github.com/microsoft/vscode-dev-containers/blob/5a084a93b0736ea86395ac99019a5b72a00b6341/containers/javascript-node-mongo/.devcontainer/Dockerfile
-# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
-ARG VARIANT=18
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
+FROM node:20-alpine
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
@@ -17,4 +14,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
# Adapted from https://www.digitalocean.com/community/tutorials/how-to-build-a-node-js-application-with-docker
WORKDIR /website
EXPOSE 3005
-CMD npm install --legacy-peer-deps && npm run dev
+CMD npm install && npm run dev
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index afb98ae0c..94198add2 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,7 +1,23 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
version: 2
updates:
-- package-ecosystem: github-actions
- directory: "/"
- schedule:
- interval: daily
- open-pull-requests-limit: 10
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+ # # Create a group of dependencies to be updated together in one pull request
+ # groups:
+ # # Specify a name for the group, which will be used in pull request titles
+ # # and branch names
+ # gh-actions:
+ # # Define patterns to include dependencies in the group (based on
+ # # dependency name)
+ # patterns:
+ # # - "rubocop" # A single dependency name
+ # # - "rspec*" # A wildcard string that matches multiple dependency names
+ # - "*" # A wildcard that matches all dependencies in the package
+ # # ecosystem. Note: using "*" may open a large pull request
diff --git a/docs/assets/images/sailbot_workspace/workflow/sailbot_bug.png b/docs/assets/images/sailbot_workspace/workflow/sailbot_bug.png
index d98be96e2..a84a83a03 100644
Binary files a/docs/assets/images/sailbot_workspace/workflow/sailbot_bug.png and b/docs/assets/images/sailbot_workspace/workflow/sailbot_bug.png differ
diff --git a/docs/current/sailbot_workspace/how_to.md b/docs/current/sailbot_workspace/how_to.md
index e8956e018..ae29e0ff0 100644
--- a/docs/current/sailbot_workspace/how_to.md
+++ b/docs/current/sailbot_workspace/how_to.md
@@ -33,8 +33,8 @@ For prefixes that are words, you will have to append a space to them to bring up
We have containerized the following applications for a variety of reasons:
- [MongoDB database](https://www.mongodb.com/){target=_blank}
-- [Docs site](https://github.com/UBCSailbot/docs){target=_blank}
-- [Website](https://github.com/UBCSailbot/website){target=_blank}
+- [Docs site](https://ubcsailbot.github.io/sailbot_workspace/main/){target=_blank}
+- [Website](https://github.com/UBCSailbot/sailbot_workspace/tree/main/src/website){target=_blank}
### Running containerized applications
diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md
index 2781dcbe7..a14c56b8d 100644
--- a/docs/reference/markdown.md
+++ b/docs/reference/markdown.md
@@ -55,12 +55,12 @@ experience is required to contribute to our docs.
Material for MkDocs supports powerful features purpose-built to take technical documentation to the next level.
Feel free to browse this site to see how we use these features, exploring their syntax in the
-[source code](https://github.com/UBCSailbot/docs/tree/main/docs){target=_blank}. Since GitHub renders Markdown files automatically
-you will need to click the "Raw" button to view their contents.
+[source code](https://github.com/UBCSailbot/sailbot_workspace/tree/main/docs){target=_blank}.
+Since GitHub renders Markdown files automatically you will need to click the "Raw" button to view their contents.
!!! note "Material-Flavoured Markdown"
- Material for MkDocs' flavour of Markdown extends upon vanilla Markdown, adding features such as admonitions
+ Material for MkDocs' flavour of Markdown extends upon vanilla Markdown, adding features such as admonitions
(like this note) and content tabs. Refer to the
[official Material for MkDocs reference page](https://squidfunk.github.io/mkdocs-material/reference/){target=_blank}
for more information on the available features.
@@ -85,7 +85,7 @@ resources are good for rendering Markdown:
=== ":logo: Material for MkDocs"
- UBC Sailbot Docs: To preview your changes when working on this site,
- refer to the [run instructions in the `README.md`](https://github.com/UBCSailbot/docs#run){target=_blank}.
+ refer to the [How to work with containerized applications](../current/sailbot_workspace/how_to.md#work-with-containerized-applications){target=_blank}.
- Material for MkDocs sites in general: If you ever decide to write your own documentation using Material for MkDocs,
refer to the [official "Getting Started" guide](https://squidfunk.github.io/mkdocs-material/getting-started/){target=_blank}.
@@ -98,10 +98,10 @@ to browse around for the solution that suits your needs.
We lint our Markdown files to reduce errors and increase readability. In particular, we use two tools:
1. [markdownlint](https://github.com/DavidAnson/markdownlint){target=_blank} is
-used to enforce a style guide. Its configuration file for this repository is [`.markdownlint.json`](https://github.com/UBCSailbot/docs/blob/main/.markdownlint.json){target=_blank}.
+used to enforce a style guide. Its configuration file for this repository is [`.markdownlint.json`](https://github.com/UBCSailbot/sailbot_workspace/blob/main/.markdownlint.json){target=_blank}.
If you use VS Code, there is a [markdownlint extension](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint){target=_blank}.
2. [markdown-link-check](https://github.com/tcort/markdown-link-check){target=_blank} is
-used to check for broken links. Its configuration file for this repository is [`.markdown-link-check.json`](https://github.com/UBCSailbot/docs/blob/main/.markdown-link-check.json){target=_blank}.
+used to check for broken links. Its configuration file for this repository is [`.markdown-link-check.json`](https://github.com/UBCSailbot/sailbot_workspace/blob/main/.markdown-link-check.json){target=_blank}.
[^1]:
diff --git a/mkdocs.yml b/mkdocs.yml
index f5d24c27b..c1cc6a49a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -4,9 +4,9 @@ site_url: https://UBCSailbot.github.io/sailbot_workspace/
site_author: UBCSailbot Software Team
# Repository
-repo_name: UBCSailbot/docs
-repo_url: https://github.com/UBCSailbot/docs
-edit_uri: "edit/main/docs/"
+repo_name: UBCSailbot/sailbot_workspace
+repo_url: https://github.com/UBCSailbot/sailbot_workspace
+edit_uri: "edit/main/sailbot_workspace/"
# Configuration
theme:
diff --git a/sailbot.code-workspace b/sailbot.code-workspace
index 24223190c..3589961d4 100644
--- a/sailbot.code-workspace
+++ b/sailbot.code-workspace
@@ -414,7 +414,7 @@
"label": "lint_cmake",
"detail": "Run lint on cmake files.",
"type": "shell",
- "command": "LINTER=lint_cmake LOCAL_RUN=true .github/actions/ament-lint/run.sh",
+ "command": "LINTER=lint_cmake LOCAL_RUN=true .github/actions/run-in-container/ament-lint.sh",
"problemMatcher": [
"$ament_lint_cmake"
],
@@ -427,7 +427,7 @@
"label": "clang-tidy",
"detail": "Run clang-tidy static analysis",
"type": "shell",
- "command": "LOCAL_RUN=true .github/actions/clang-tidy/run.sh",
+ "command": "LOCAL_RUN=true .github/actions/run-in-container/clang-tidy.sh",
"problemMatcher": [],
"presentation": {
"panel": "dedicated",
@@ -438,7 +438,7 @@
"label": "flake8",
"detail": "Run flake8 on python files.",
"type": "shell",
- "command": "LINTER=flake8 LOCAL_RUN=true .github/actions/ament-lint/run.sh",
+ "command": "LINTER=flake8 LOCAL_RUN=true .github/actions/run-in-container/ament-lint.sh",
"problemMatcher": [
"$ament_flake8"
],
@@ -451,7 +451,7 @@
"label": "mypy",
"detail": "Run mypy on python files.",
"type": "shell",
- "command": "LINTER=mypy LOCAL_RUN=true .github/actions/ament-lint/run.sh",
+ "command": "LINTER=mypy LOCAL_RUN=true .github/actions/run-in-container/ament-lint.sh",
"problemMatcher": [
"$ament_mypy",
],
@@ -464,7 +464,7 @@
"label": "xmllint",
"detail": "Run xmllint on xml files.",
"type": "shell",
- "command": "LINTER=xmllint LOCAL_RUN=true .github/actions/ament-lint/run.sh",
+ "command": "LINTER=xmllint LOCAL_RUN=true .github/actions/run-in-container/ament-lint.sh",
"problemMatcher": [
"$ament_xmllint",
],
diff --git a/setup.sh b/setup.sh
index ab0425f87..b65ac1040 100755
--- a/setup.sh
+++ b/setup.sh
@@ -1,6 +1,20 @@
#!/bin/bash
set -e
+# Create/overwrite the custom rosdep list file
+CUSTOM_ROSDEP_LIST="/etc/ros/rosdep/sources.list.d/20-sailbot.list"
+CUSTOM_ROSDEP_FILE="custom-rosdep.yaml"
+echo "# sailbot" | sudo tee $CUSTOM_ROSDEP_LIST > /dev/null
+for DIR in $ROS_WORKSPACE/src/*; do
+ if [ -d "$DIR" ]; then
+ FILE="$DIR/$CUSTOM_ROSDEP_FILE"
+ if [ -f $FILE ]; then
+ echo "Adding $FILE to $CUSTOM_ROSDEP_LIST"
+ echo "yaml file://$FILE" | sudo tee --append $CUSTOM_ROSDEP_LIST > /dev/null
+ fi
+ fi
+done
+
sudo apt-get update
rosdep update --rosdistro $ROS_DISTRO
rosdep install --from-paths src --ignore-src -y --rosdistro $ROS_DISTRO
diff --git a/src/boat_simulator/README.md b/src/boat_simulator/README.md
index 26ce3f565..24ea423bc 100644
--- a/src/boat_simulator/README.md
+++ b/src/boat_simulator/README.md
@@ -1,7 +1,5 @@
# UBC Sailbot Boat Simulator
-[](https://github.com/UBCSailbot/boat_simulator/actions/workflows/tests.yml)
-
UBC Sailbot's boat simulator for the new project. This repository contains a ROS package `boat_simulator`. This README
contains only setup and run instructions. Further information on the boat simulator can be found on the software
team's [docs website](https://ubcsailbot.github.io/sailbot_workspace/main/current/boat_simulator/overview/).
diff --git a/src/boat_simulator/boat_simulator/common/sensors.py b/src/boat_simulator/boat_simulator/common/sensors.py
index a03940815..d1bf9d3b1 100644
--- a/src/boat_simulator/boat_simulator/common/sensors.py
+++ b/src/boat_simulator/boat_simulator/common/sensors.py
@@ -1,24 +1,43 @@
-from dataclasses import dataclass
-
-from typing import Optional, Any
+from typing import Any
from numpy.typing import NDArray
-
+from typing import List
+import numpy as np
from boat_simulator.common.types import Scalar, ScalarOrArray
from boat_simulator.common.generators import (
- ConstantGenerator,
MVGaussianGenerator,
GaussianGenerator,
)
-WindSensorGenerators = Optional[MVGaussianGenerator | ConstantGenerator]
-GPSGenerators = Optional[GaussianGenerator | ConstantGenerator]
-
-@dataclass
class Sensor:
- """Interface for sensors in the Boat Simulation."""
+ """
+
+ Interface for sensors in the Boat Simulation.
+
+ Data delay and noise models are supported.
+
+ Delay model will delay sensor value updates by one update cycle:
+ - Sensor has initial data x0 at t = 0
+ - Sensor provided new data x_1 at t = 1
+ - Sensor provided new data x_2 at t = 2. At t = 2, x1 is registered into the sensor.
+ - Sensor provided new data x_i at t = i. At t = i, x_{i-1} is registered into the sensor.
+
+ Noise model will add noise to sensor values drawn from a
+ Gaussian or Multi-variate Gaussian distribution.
+ """
+
+ def __init__(self, enable_delay: bool = False, enable_noise: bool = False) -> None:
+ """
+
+ Args:
+ enable_noise (bool): Enables noise for fields. False by default.
+ enable_delay (bool): Enables delay for fields. False by default.
+ """
+
+ self.enable_delay = enable_delay
+ self.enable_noise = enable_noise
def update(self, **kwargs):
"""
@@ -29,6 +48,7 @@ def update(self, **kwargs):
Raises:
ValueError: If kwarg is not a defined attribute in Sensor
"""
+
for attr_name, attr_val in kwargs.items():
if attr_name in self.__annotations__:
setattr(self, attr_name, attr_val)
@@ -60,98 +80,181 @@ def read(self, key: str) -> Any:
)
-@dataclass
class WindSensor(Sensor):
"""
Abstraction for wind sensor.
- # TODO: Add delay functions.
-
Properties:
wind (ScalarOrArray): Wind x, y components or single value
- wind_noisemaker (Optional[MVGaussianGenerator | ConstantGenerator]):
- Noise function to emulate sensor noise in wind data reading
+ enable_noise (bool): Enables noise for fields. False by default.
+ enable_delay (bool): Enables delay for fields. False by default.
"""
wind: ScalarOrArray
- wind_noisemaker: WindSensorGenerators = None
+
+ def __init__(
+ self,
+ wind: ScalarOrArray,
+ wind_noise_stdev: List[Scalar] = [1.0, 1.0],
+ enable_noise: bool = False,
+ enable_delay: bool = False,
+ ) -> None:
+ super().__init__(enable_noise=enable_noise, enable_delay=enable_delay)
+ self._wind = wind
+
+ # TODO: Refactor the initialization of data fields and their respective delay controls.
+ # Warning: this is not easy!
+
+ self.wind_queue_next: bool = False
+ self.wind_next_value: ScalarOrArray = wind
+ self.wind_noisemaker: MVGaussianGenerator = MVGaussianGenerator(
+ mean=np.array([0, 0]), cov=np.diag(np.power(wind_noise_stdev, 2))
+ )
@property # type: ignore
def wind(self) -> ScalarOrArray:
# TODO: Ensure attribute value and noisemakers are using the same value shape.
# - wind scalars should add with noise scalars.
# - wind vectors should add with noise vectors.
- # Could consider using a __post_init__ function for this
+
return (
self._wind + self.wind_noisemaker.next() # type: ignore
- if self.wind_noisemaker is not None
+ if self.enable_noise
else self._wind
)
@wind.setter
def wind(self, wind: ScalarOrArray):
- self._wind = wind
+
+ if not self.enable_delay:
+ self._wind = wind
+ return
+
+ if self.wind_queue_next:
+ self._wind = self.wind_next_value
+ else:
+ self.wind_queue_next = True
+
+ self.wind_next_value = wind
-@dataclass
class GPS(Sensor):
"""
Abstraction for GPS.
- # TODO: Add delay functions.
-
Properties:
lat_lon (NDArray): Boat latitude and longitude (2x1 array)
speed (Scalar): Boat speed
heading (Scalar): Boat heading
- lat_lon_noisemaker (Optional[GaussianGenerator | ConstantGenerator]):
- Noise function to emulate sensor noise in latitude and longitude readings
- speed_noisemaker (Optional[GaussianGenerator | ConstantGenerator]):
- Noise function to emulate sensor noise in speed readings
- heading_noisemaker (Optional[GaussianGenerator | ConstantGenerator]):
- Noise function to emulate sensor noise in heading readings
+ enable_noise (bool): Enables noise for fields. False by default.
+ enable_delay (bool): Enables delay for fields. False by default.
"""
lat_lon: NDArray
speed: Scalar
heading: Scalar
- lat_lon_noisemaker: GPSGenerators = None
- speed_noisemaker: GPSGenerators = None
- heading_noisemaker: GPSGenerators = None
+ def __init__(
+ self,
+ lat_lon: NDArray,
+ speed: Scalar,
+ heading: Scalar,
+ lat_lon_noise_stdev: Scalar = 1,
+ speed_noise_stdev: Scalar = 1,
+ heading_noise_stdev: Scalar = 1,
+ enable_noise: bool = False,
+ enable_delay: bool = False,
+ ):
+ super().__init__(enable_noise=enable_noise, enable_delay=enable_delay)
+ self._lat_lon = lat_lon
+ self._speed = speed
+ self._heading = heading
+
+ # TODO: Refactor the initialization of data fields and their respective delay controls.
+ # Warning: this is not easy!
+
+ # Delay Controls
+ self.lat_lon_queue_next: bool = False
+ self.lat_lon_next_value: NDArray = lat_lon
+
+ self.speed_queue_next: bool = False
+ self.speed_next_value: Scalar = speed
+
+ self.heading_queue_next: bool = False
+ self.heading_next_value: Scalar = heading
+
+ self.lat_lon_noisemaker: GaussianGenerator = GaussianGenerator(
+ mean=0, stdev=lat_lon_noise_stdev
+ )
+ self.speed_noisemaker: GaussianGenerator = GaussianGenerator(
+ mean=0, stdev=speed_noise_stdev
+ )
+ self.heading_noisemaker: GaussianGenerator = GaussianGenerator(
+ mean=0, stdev=heading_noise_stdev
+ )
@property # type: ignore
def lat_lon(self) -> NDArray:
return (
self._lat_lon + self.lat_lon_noisemaker.next()
- if self.lat_lon_noisemaker is not None
+ if self.enable_noise
else self._lat_lon
)
@lat_lon.setter
def lat_lon(self, lat_lon: NDArray):
- self._lat_lon = lat_lon
+
+ if not self.enable_delay:
+ self._lat_lon = lat_lon
+ return
+
+ if self.lat_lon_queue_next:
+ self._lat_lon = self.lat_lon_next_value
+ else:
+ self.lat_lon_queue_next = True
+
+ self.lat_lon_next_value = lat_lon
@property # type: ignore
def speed(self) -> Scalar:
return (
self._speed + self.speed_noisemaker.next() # type: ignore
- if self.speed_noisemaker is not None
+ if self.enable_noise
else self._speed
)
@speed.setter
def speed(self, speed: Scalar):
- self._speed = speed
+
+ if not self.enable_delay:
+ self._speed = speed
+ return
+
+ if self.speed_queue_next:
+ self._speed = self.speed_next_value
+ else:
+ self.speed_queue_next = True
+
+ self.speed_next_value = speed
@property # type: ignore
def heading(self) -> Scalar:
return (
self._heading + self.heading_noisemaker.next() # type: ignore
- if self.heading_noisemaker is not None
+ if self.enable_noise
else self._heading
)
@heading.setter
def heading(self, heading: Scalar):
- self._heading = heading
+
+ if not self.enable_delay:
+ self._heading = heading
+ return
+
+ if self.heading_queue_next:
+ self._heading = self.heading_next_value
+ else:
+ self.heading_queue_next = True
+
+ self.heading_next_value = heading
diff --git a/src/boat_simulator/boat_simulator/common/unit_conversions.py b/src/boat_simulator/boat_simulator/common/unit_conversions.py
index 701b443a5..d41706742 100644
--- a/src/boat_simulator/boat_simulator/common/unit_conversions.py
+++ b/src/boat_simulator/boat_simulator/common/unit_conversions.py
@@ -131,6 +131,9 @@ class ConversionFactors(Enum):
km_to_nautical_mi = nautical_mi_to_km.inverse()
# Time
+ sec_to_ms = ConversionFactor(factor=1000)
+ ms_to_sec = sec_to_ms.inverse()
+
min_to_sec = ConversionFactor(factor=60)
sec_to_min = min_to_sec.inverse()
@@ -199,7 +202,9 @@ def __init__(self, **kwargs: EnumAttr):
belonging to `ConversionFactors`.
"""
for attr_name, attr_val in kwargs.items():
- assert isinstance(attr_val, Enum) and isinstance(attr_val.value, ConversionFactor)
+ assert isinstance(attr_val, Enum) and isinstance(
+ attr_val.value, ConversionFactor
+ )
setattr(self, attr_name, attr_val)
def convert(self, **kwargs: ScalarOrArray) -> Dict[str, ScalarOrArray]:
@@ -223,7 +228,9 @@ def convert(self, **kwargs: ScalarOrArray) -> Dict[str, ScalarOrArray]:
for attr_name, attr_val in kwargs.items():
attr = getattr(self, attr_name, None)
- assert attr is not None, f"Attribute name {attr} not found in UnitConverter."
+ assert (
+ attr is not None
+ ), f"Attribute name {attr} not found in UnitConverter."
conversion_factor = attr.value
converted_values[attr_name] = conversion_factor.forward_convert(attr_val)
diff --git a/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py b/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py
index c479bdb2d..770e8f343 100644
--- a/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py
+++ b/src/boat_simulator/boat_simulator/nodes/physics_engine/fluid_forces.py
@@ -2,9 +2,12 @@
from typing import Tuple
+import matplotlib.patches as patches
+import matplotlib.pyplot as plt
+import numpy as np
from numpy.typing import NDArray
-from boat_simulator.common.types import Scalar
+from boat_simulator.common.utils import Scalar
class MediumForceComputation:
@@ -18,9 +21,7 @@ class MediumForceComputation:
`drag_coefficients` (NDArray): An array of shape (n, 2) where each row contains a pair
(x, y) representing an angle of attack, in degrees, and its corresponding drag
coefficient.
- `areas` (NDArray): An array of shape (n, 2) where each row contains a pair (x, y),
- representing an angle of attack, in degrees, and its corresponding area, in square
- meters (m^2).
+ `areas` (Scalar): Corresponding area, in square meters (m^2).
`fluid_density` (Scalar): The density of the fluid acting on the medium, expressed in
kilograms per cubic meter (kg/m^3).
"""
@@ -29,7 +30,7 @@ def __init__(
self,
lift_coefficients: NDArray,
drag_coefficients: NDArray,
- areas: NDArray,
+ areas: Scalar,
fluid_density: Scalar,
):
self.__lift_coefficients = lift_coefficients
@@ -37,6 +38,40 @@ def __init__(
self.__areas = areas
self.__fluid_density = fluid_density
+ def calculate_attack_angle(self, apparent_velocity: NDArray, orientation: Scalar) -> Scalar:
+ """Calculates the angle of attack formed between the orientation angle of the medium
+ and the direction of the apparent velocity, bounded between -180 and 180 degrees.
+
+ Args:
+ apparent_velocity (NDArray): The apparent (relative) velocity between the fluid and
+ the medium, expressed in meters per second (m/s).
+ orientation (Scalar): The orientation angle of the medium in degrees.
+
+ Returns:
+ Scalar: The angle of attack formed between the orientation angle of the medium and
+ the direction of the apparent velocity, expressed in degrees
+ and bounded between -180 and 180 degrees.
+ """
+ # Check if the apparent velocity is [0, 0]
+ if np.all(apparent_velocity == 0):
+ # Directly return the normalized orientation as the angle of attack
+ # Normalize orientation to be within [-180, 180)
+ return ((orientation + 180) % 360) - 180
+
+ # Calculate the angle in degrees of the apparent velocity
+ angle_of_attack_raw = np.rad2deg(np.arctan2(apparent_velocity[1], apparent_velocity[0]))
+
+ # Adjust orientation to be in the range of [-180, 180)
+ orientation = ((orientation + 180) % 360) - 180
+
+ # Calculate the raw angle of attack by subtracting the orientation from the velocity angle
+ angle_of_attack = angle_of_attack_raw - orientation
+
+ # Normalize the angle of attack to [-180, 180) range
+ angle_of_attack = ((angle_of_attack + 180) % 360) - 180
+
+ return angle_of_attack
+
def compute(self, apparent_velocity: NDArray, orientation: Scalar) -> Tuple[NDArray, NDArray]:
"""Computes the lift and drag forces experienced by a medium immersed in a fluid.
@@ -52,11 +87,97 @@ def compute(self, apparent_velocity: NDArray, orientation: Scalar) -> Tuple[NDAr
by the medium, both expressed in newtons (N).
"""
- # TODO: Implement this method.
+ attack_angle = self.calculate_attack_angle(apparent_velocity, orientation)
+ lift_coefficient, drag_coefficient = self.interpolate(attack_angle)
+ velocity_magnitude = np.linalg.norm(apparent_velocity)
+
+ # Calculate the lift and drag forces
- raise NotImplementedError()
+ lift_force_magnitude = self.__calculate_fluid_force_magnitude(
+ lift_coefficient, velocity_magnitude
+ )
+ drag_force_magnitude = self.__calculate_fluid_force_magnitude(
+ drag_coefficient, velocity_magnitude
+ )
- def interpolate(self, attack_angle: Scalar) -> Tuple[Scalar, Scalar, Scalar]:
+ drag_force_unit_vector = apparent_velocity / velocity_magnitude
+ drag_force_unit_vector = self.__rotate_vector(drag_force_unit_vector, orientation)
+
+ # Rotate the lift and drag forces by 90 degrees to obtain the lift and drag forces
+
+ # Convention used here is that the positive x-axis is 0 degrees
+ # and the positive y-axis is 90 degrees
+ # Positive rotation is counter clockwise
+
+ is_drag_in_first_or_third_quadrant = (
+ drag_force_unit_vector[0] > 0 and drag_force_unit_vector[1] > 0
+ ) or (drag_force_unit_vector[0] < 0 and drag_force_unit_vector[1] < 0)
+
+ is_drag_in_second_or_fourth_quadrant = (
+ drag_force_unit_vector[0] > 0 and drag_force_unit_vector[1] < 0
+ ) or (drag_force_unit_vector[0] < 0 and drag_force_unit_vector[1] > 0)
+
+ # Rotate the lift force direction based on the quadrant of the drag force
+ if is_drag_in_first_or_third_quadrant:
+ # Rotate counter clockwise to get lift direction
+ lift_force_direction = np.array(
+ [-drag_force_unit_vector[1], drag_force_unit_vector[0]]
+ )
+ elif is_drag_in_second_or_fourth_quadrant:
+ # Rotate clockwise to get lift direction
+ lift_force_direction = np.array(
+ [drag_force_unit_vector[1], -drag_force_unit_vector[0]]
+ )
+ else:
+ # Should not happen if drag force direction is properly normalized
+ # This could be a fallback for an unexpected case
+ lift_force_direction = np.array([0, 0])
+
+ # Rotate the lift and drag forces back to the original orientation
+ lift_force_direction = self.__rotate_vector(
+ lift_force_direction, orientation, clockwise=False
+ )
+ drag_force_unit_vector = self.__rotate_vector(
+ drag_force_unit_vector, orientation, clockwise=False
+ )
+
+ lift_force = lift_force_magnitude * lift_force_direction
+ drag_force = drag_force_magnitude * drag_force_unit_vector
+
+ return lift_force, drag_force
+
+ def __calculate_fluid_force_magnitude(
+ self, coefficient: Scalar, velocity_magnitude: Scalar
+ ) -> Scalar:
+ """Calculates the magnitude of fluid forces based on coefficient and velocity."""
+ return 0.5 * self.__fluid_density * coefficient * self.__areas * (velocity_magnitude**2)
+
+ def __rotate_vector(self, v: NDArray, theta_degrees: Scalar, clockwise=True) -> NDArray:
+ """
+ Rotates a vector by a specified angle in degrees.
+
+ Args:
+ v (np.array): The vector to be rotated.
+ theta_degrees (float): The rotation angle in degrees.
+ clockwise (bool, optional): Determines the direction of rotation. If True (default),
+ rotates the vector clockwise. If False, rotates the vector
+ counterclockwise.
+
+ Returns:
+ np.array: The rotated vector.
+ """
+ theta_radians = np.deg2rad(theta_degrees)
+ sign = 1 if clockwise else -1
+ rotation_matrix = np.array(
+ [
+ [np.cos(theta_radians), sign * np.sin(theta_radians)],
+ [-sign * np.sin(theta_radians), np.cos(theta_radians)],
+ ]
+ )
+ v_rotated = np.dot(rotation_matrix, v)
+ return v_rotated
+
+ def interpolate(self, attack_angle: Scalar) -> Tuple[Scalar, Scalar]:
"""Performs linear interpolation to estimate the lift and drag coefficients, as well as the
associated area upon which the fluid acts, based on the provided angle of attack.
@@ -65,16 +186,147 @@ def interpolate(self, attack_angle: Scalar) -> Tuple[Scalar, Scalar, Scalar]:
the medium and the direction of the apparent velocity, expressed in degrees.
Returns:
- Tuple[Scalar, Scalar, Scalar]: A tuple representing the computed parameters. The
+ Tuple[Scalar, Scalar]: A tuple representing the computed parameters. The
first scalar denotes the lift coefficient, the second scalar represents the
drag coefficient, and the third scalar indicates the surface area upon which
the fluid acts. Both lift and drag coefficients are unitless, while the
area is expressed in square meters (m^2).
"""
- # TODO: Implement this method using `np.interp`.
+ lift_coefficient = np.interp(
+ attack_angle, self.__lift_coefficients[:, 0], self.__lift_coefficients[:, 1]
+ )
+ drag_coefficient = np.interp(
+ attack_angle, self.__drag_coefficients[:, 0], self.__drag_coefficients[:, 1]
+ )
+ return lift_coefficient, drag_coefficient
+
+ def _draw_boat(ax, position, orientation):
+ """Draws a simplified boat shape on the given axes, ensuring it aligns
+ with the orientation line."""
+ boat_length = 1.0
+ boat_width = 0.3
+
+ # Center the shape around (0, 0)
+ front_extension = 3 * boat_length / 4
+ rear_extension = -boat_length / 4
+
+ # Calculate the offset to center the shape
+ offset = front_extension + rear_extension
+
+ boat_shape = np.array(
+ [
+ [front_extension - offset, 0],
+ [boat_length / 2 - offset, boat_width / 2],
+ [rear_extension - offset, 0],
+ [boat_length / 2 - offset, -boat_width / 2],
+ [front_extension - offset, 0],
+ ]
+ )
+
+ # Rotation matrix for anticlockwise rotation
+ rotation_matrix = np.array(
+ [
+ [np.cos(np.deg2rad(orientation)), np.sin(np.deg2rad(orientation))],
+ [np.sin(np.deg2rad(orientation)), np.cos(np.deg2rad(orientation))],
+ ]
+ )
+
+ # Apply rotation
+ rotated_boat = np.dot(boat_shape, rotation_matrix)
+
+ # Translate boat to its position
+ translated_boat = rotated_boat + np.array(position)
+
+ # Draw the boat
+ ax.plot(translated_boat[:, 0], translated_boat[:, 1], "k")
+
+ # Ensure the orientation line is drawn correctly
+ # Calculate a point along the orientation direction
+ direction = np.array([np.cos(np.deg2rad(orientation)), np.sin(np.deg2rad(orientation))])
+ line_start = np.array(position)
+ line_end = (
+ line_start + direction * boat_length
+ ) # Extend the line out from the boat's position
+
+ # Draw orientation line
+ ax.plot(
+ [line_start[0], line_end[0]], [line_start[1], line_end[1]], "black", linestyle="--"
+ )
+
+ def visualize_forces(
+ self, apparent_velocity, lift_force, drag_force, position=[0, 0], orientation=0
+ ):
+ """Visualizes the sailboat, apparent velocity, lift force, and drag force."""
+ fig, ax = plt.subplots()
+ attack_angle = self.calculate_attack_angle(apparent_velocity, orientation)
+ # Normalize forces for visualization
+ norm_apparent_velocity = apparent_velocity / np.linalg.norm(apparent_velocity)
+ norm_lift_force = lift_force / np.linalg.norm(lift_force)
+ norm_drag_force = drag_force / np.linalg.norm(drag_force)
+
+ # Draw the boat
+ MediumForceComputation._draw_boat(ax, position, orientation)
+ # Plot forces and velocity
+ ax.quiver(
+ position[0],
+ position[1],
+ norm_apparent_velocity[0],
+ norm_apparent_velocity[1],
+ color="blue",
+ scale=5,
+ label="Apparent Velocity",
+ pivot="tip",
+ )
+ ax.quiver(
+ position[0],
+ position[1],
+ norm_lift_force[0],
+ norm_lift_force[1],
+ color="red",
+ scale=5,
+ label="Lift Force",
+ )
+ ax.quiver(
+ position[0],
+ position[1],
+ norm_drag_force[0],
+ norm_drag_force[1],
+ color="green",
+ scale=5,
+ label="Drag Force",
+ )
+ orientation_rad = np.deg2rad(orientation) # Convert orientation to radians
+ ax.axline((0, 0), slope=np.tan(orientation_rad), color="black", linestyle="--")
+
+ # Calculate angle for drag force
+ drag_angle = np.arctan2(norm_drag_force[1], norm_drag_force[0])
+
+ # Determine start and end angles for the arc
+ start_angle = np.rad2deg(orientation_rad)
+ end_angle = np.rad2deg(drag_angle)
+
+ # Draw arc to represent angle between orientation and drag force
+ radius = 0.05
+ arc = patches.Arc(
+ position,
+ 2 * radius,
+ 2 * radius,
+ angle=0,
+ theta1=min(start_angle, end_angle),
+ theta2=max(start_angle, end_angle),
+ color="purple",
+ label="Angle Arc",
+ )
+ ax.add_patch(arc)
- raise NotImplementedError()
+ ax.axis("equal")
+ ax.legend()
+ plt.title("Forces Acting on Sailboat for Attack Angle: " + str(round(attack_angle)))
+ plt.xlabel("X-axis")
+ plt.ylabel("Y-axis")
+ plt.grid(True)
+ plt.show()
@property
def lift_coefficients(self) -> NDArray:
@@ -85,7 +337,7 @@ def drag_coefficients(self) -> NDArray:
return self.__drag_coefficients
@property
- def areas(self) -> NDArray:
+ def areas(self) -> Scalar:
return self.__areas
@property
diff --git a/src/boat_simulator/package.xml b/src/boat_simulator/package.xml
index 55401ed73..cec78dd46 100644
--- a/src/boat_simulator/package.xml
+++ b/src/boat_simulator/package.xml
@@ -11,6 +11,7 @@
ament_flake8
ament_pep257
python3-pytest
+ python3-matplotlib
custom_interfaces
@@ -20,6 +21,7 @@
python3-numpy
python3-scipy
+ python3-matplotlib
ament_python
diff --git a/src/boat_simulator/tests/unit/common/test_gps_sensor.py b/src/boat_simulator/tests/unit/common/test_gps_sensor.py
index 60f2568d8..90a3ab982 100644
--- a/src/boat_simulator/tests/unit/common/test_gps_sensor.py
+++ b/src/boat_simulator/tests/unit/common/test_gps_sensor.py
@@ -1,9 +1,5 @@
from boat_simulator.common.sensors import GPS
import numpy as np
-from boat_simulator.common.generators import (
- ConstantGenerator,
- GaussianGenerator,
-)
class TestGPS:
@@ -11,96 +7,38 @@ def test_gps_init(self):
lat_lon = np.array([1, 0])
speed = 100
heading = 1.09
- error_fn = None
gps = GPS(
lat_lon=lat_lon,
speed=speed,
heading=heading,
- lat_lon_noisemaker=error_fn,
- speed_noisemaker=error_fn,
- heading_noisemaker=error_fn,
)
assert (gps.lat_lon == lat_lon).all()
assert gps.speed == speed
assert gps.heading == heading
- assert gps.lat_lon_noisemaker is error_fn
- assert gps.speed_noisemaker is error_fn
- assert gps.heading_noisemaker is error_fn
- def test_gps_init_implicit_error_fn(self):
- lat_lon = np.array([1, 0])
- speed = 100
- heading = 1.09
-
- gps = GPS(
- lat_lon=lat_lon,
- speed=speed,
- heading=heading,
- )
-
- assert (gps.lat_lon == lat_lon).all()
- assert gps.speed == speed
- assert gps.heading == heading
- for noisemaker in [
- gps.lat_lon_noisemaker,
- gps.speed_noisemaker,
- gps.heading_noisemaker,
- ]:
- assert noisemaker is None
-
- def test_gps_read_no_error(self):
+ def test_gps_read_no_noise(self):
lat_lon = np.array([1, 0])
speed = np.random.randint(0, 100)
heading = np.random.rand()
- gps = GPS(
- lat_lon=lat_lon,
- speed=speed,
- heading=heading,
- )
+ gps = GPS(lat_lon=lat_lon, speed=speed, heading=heading, enable_noise=False)
- assert (gps.read("lat_lon") == lat_lon).all()
+ assert np.all(gps.read("lat_lon") == lat_lon)
assert gps.read("speed") == speed
assert gps.read("heading") == heading
- def test_gps_read_constant_error(self):
- lat_lon = np.array([1, 0])
- speed = np.random.randint(0, 100)
- heading = np.random.rand()
- constant = 3.01
- error_fn = ConstantGenerator(constant=constant)
-
- gps = GPS(
- lat_lon=lat_lon,
- speed=speed,
- heading=heading,
- lat_lon_noisemaker=error_fn,
- speed_noisemaker=error_fn,
- heading_noisemaker=error_fn,
- )
-
- assert (gps.read("lat_lon") == lat_lon + constant).all()
- assert gps.read("speed") == speed + constant
- assert gps.read("heading") == heading + constant
-
- def test_gps_gaussian_error(self):
+ def test_gps_gaussian_noise(self):
lat_lon = np.array([1, 0])
speed = np.random.randint(0, 100)
heading = np.random.rand()
mean = 0
- stdev = 1
-
- error_fn = GaussianGenerator(mean=mean, stdev=stdev)
gps = GPS(
lat_lon=lat_lon,
speed=speed,
heading=heading,
- lat_lon_noisemaker=error_fn,
- speed_noisemaker=error_fn,
- heading_noisemaker=error_fn,
)
NUM_READINGS = 10000
@@ -117,9 +55,9 @@ def test_gps_gaussian_error(self):
[speed, heading, lat_lon],
):
sample_mean = np.mean(reading, axis=0)
- assert np.isclose(sample_mean, mean + init_data, atol=0.1).all()
+ assert np.allclose(sample_mean, mean + init_data, atol=0.1)
- def test_wind_sensor_update(self):
+ def test_gps_sensor_update(self):
lat_lon = np.array([0, 0])
speed = 0
heading = 0
@@ -143,3 +81,17 @@ def test_wind_sensor_update(self):
lat_lon_reading = gps.read("lat_lon")
assert (lat_lon_reading == np.array([i, i])).all()
gps.update(lat_lon=(lat_lon_reading + 1))
+
+ def test_gps_sensor_update_delay(self):
+ lat_lon = np.array([0, 0])
+ speed0 = 0
+ heading = 0
+
+ # Initialized data is read without delay
+ gps = GPS(lat_lon=lat_lon, speed=speed0, heading=heading, enable_delay=True)
+ assert gps.read("speed") == speed0
+
+ NUM_UPDATES = 3
+ for i in range(NUM_UPDATES):
+ gps.update(speed=(i + 1))
+ assert gps.read("speed") == i
diff --git a/src/boat_simulator/tests/unit/common/test_wind_sensor.py b/src/boat_simulator/tests/unit/common/test_wind_sensor.py
index aed0d1c88..f31512686 100644
--- a/src/boat_simulator/tests/unit/common/test_wind_sensor.py
+++ b/src/boat_simulator/tests/unit/common/test_wind_sensor.py
@@ -1,59 +1,29 @@
from boat_simulator.common.sensors import WindSensor
import numpy as np
-from boat_simulator.common.generators import (
- MVGaussianGenerator,
- ConstantGenerator,
-)
class TestWindSensor:
def test_wind_sensor_init(self):
init_data = np.array([1, 0])
- error_fn = None
ws = WindSensor(
wind=init_data,
- wind_noisemaker=error_fn,
)
- assert ws.wind_noisemaker == error_fn
assert np.all(ws.wind == init_data)
- def test_wind_sensor_init_implicit_error_fn(self):
+ def test_wind_sensor_read_no_noise(self):
init_data = np.array([1, 0])
- ws = WindSensor(wind=init_data)
-
- assert ws.wind_noisemaker is None
- assert np.all(ws.wind == init_data)
-
- def test_wind_sensor_read_no_error(self):
- init_data = np.array([1, 0])
- ws = WindSensor(
- wind=init_data,
- )
- read_data = ws.read("wind")
- assert (init_data == read_data).all()
-
- def test_wind_sensor_read_constant_error(self):
- init_data = np.array([1, 0])
- const_err = 0.1
- error_fn = ConstantGenerator(constant=0.1)
ws = WindSensor(
wind=init_data,
- wind_noisemaker=error_fn,
)
-
read_data = ws.read("wind")
- assert ((init_data + const_err) == read_data).all()
+ assert np.all(init_data == read_data)
- def test_wind_sensor_read_mv_gaussian_error(self):
- init_data = np.array([1, 0])
- mean = np.array([1, 1])
+ def test_wind_sensor_read_mv_gaussian_noise(self):
+ init_data = np.array([0, 0])
+ mean = np.array([0, 0])
cov = np.eye(2)
- error_fn = MVGaussianGenerator(mean=mean, cov=cov)
- ws = WindSensor(
- wind=init_data,
- wind_noisemaker=error_fn,
- )
+ ws = WindSensor(wind=init_data, enable_noise=True)
NUM_READINGS = 10000
reading = np.zeros(shape=(NUM_READINGS, mean.size))
@@ -63,15 +33,35 @@ def test_wind_sensor_read_mv_gaussian_error(self):
sample_mean = np.mean(reading, axis=0)
sample_cov = np.cov(reading, rowvar=False)
+ assert np.allclose(sample_mean, mean, atol=0.2)
assert np.allclose(sample_cov, cov, atol=0.2)
- assert np.isclose(sample_mean, mean + init_data, 0.1).all()
- def test_wind_sensor_update(self):
- init_data = np.zeros(2)
+ def test_wind_sensor_update_no_delay(self):
+ init_data = np.array([0, 0])
ws = WindSensor(wind=init_data)
NUM_READINGS = 100
for i in range(NUM_READINGS):
wind = ws.read("wind")
- assert (wind == np.array([i, i])).all()
+ assert np.all(wind == np.array([i, i]))
ws.update(wind=(wind + 1))
+
+ def test_wind_sensor_update_with_delay(self):
+ """
+ Attempt to constantly update wind sensor with new data.
+ Delay causes new data to be read in the next update cycle.
+ """
+
+ init_data = np.array([0, 0])
+
+ ws = WindSensor(wind=init_data, enable_delay=True)
+
+ wind = ws.read("wind")
+ # Initialized data is read without delay
+ assert np.all(wind == init_data)
+
+ NUM_UPDATES = 3
+ for i in range(NUM_UPDATES):
+ ws.update(wind=np.array([i + 1, i + 1]))
+ wind = ws.read("wind")
+ assert np.all(wind == [i, i])
diff --git a/src/boat_simulator/tests/unit/nodes/physics_engine/test_fluid_forces.py b/src/boat_simulator/tests/unit/nodes/physics_engine/test_fluid_forces.py
new file mode 100644
index 000000000..349df2dc6
--- /dev/null
+++ b/src/boat_simulator/tests/unit/nodes/physics_engine/test_fluid_forces.py
@@ -0,0 +1,102 @@
+import math
+
+import numpy as np
+import pytest
+
+from boat_simulator.nodes.physics_engine.fluid_forces import MediumForceComputation
+
+
+@pytest.fixture
+def medium_force_setup():
+ lift_coefficients = np.array([[0, 0], [5, 0.57], [10, 1.10], [15, 1.39], [20, 1.08]])
+ drag_coefficients = np.array([[0, 0.013], [5, 0.047], [10, 0.144], [15, 0.279], [20, 0.298]])
+ areas = 9.0
+ fluid_density = 1.225
+
+ computation = MediumForceComputation(
+ lift_coefficients, drag_coefficients, areas, fluid_density
+ )
+ return computation
+
+
+def test_initialization(medium_force_setup):
+ assert isinstance(medium_force_setup.lift_coefficients, np.ndarray)
+ assert isinstance(medium_force_setup.drag_coefficients, np.ndarray)
+ assert isinstance(medium_force_setup.areas, (int, float))
+ assert isinstance(medium_force_setup.fluid_density, (int, float))
+
+
+@pytest.mark.parametrize(
+ "apparent_velocity, orientation, expected_angle",
+ [
+ # Test zero apparent velocity with various orientations,
+ # including edge cases and normalization
+ (np.array([0, 0]), 0, 0),
+ (np.array([0, 0]), 45, 45),
+ (np.array([0, 0]), 90, 90),
+ (np.array([0, 0]), 180, -180), # Normalized to -180
+ (np.array([0, 0]), 270, -90), # Normalized to -90
+ (np.array([0, 0]), 360, 0),
+ (np.array([0, 0]), -45, -45), # Test negative orientation
+ (np.array([0, 0]), 405, 45), # Orientation beyond 360
+ (np.array([0, 0]), -405, -45), # Orientation below -360
+ # Test non-zero apparent velocity for comprehensive angle of attack calculations
+ (np.array([1, 0]), 0, 0),
+ (np.array([0, 1]), 0, 90),
+ (np.array([-1, 0]), 0, -180),
+ (np.array([0, -1]), 0, -90),
+ (np.array([1, 1]), 45, 0),
+ (np.array([-1, -1]), 135, 90),
+ # Edge cases where orientation and velocity directions are opposite or identical
+ (np.array([1, 0]), 180, -180),
+ (np.array([-1, 0]), 180, 0),
+ (np.array([0, 1]), 270, -180),
+ (np.array([0, -1]), 90, -180),
+ # Additional tests with non-unit vectors
+ (np.array([2, 0]), 0, 0), # Horizontal vector, twice the unit length
+ (np.array([0, 2]), 0, 90), # Vertical vector, twice the unit length
+ (np.array([-2, 0]), 0, -180), # Left horizontal, twice the unit length
+ (np.array([3, 4]), 0, np.rad2deg(np.arctan2(4, 3))), # 3-4-5 triangle vector
+ (np.array([5, 5]), 45, 0), # Diagonal upward, aligned with orientation
+ ],
+)
+def test_calculate_attack_angle(
+ apparent_velocity, orientation, expected_angle, medium_force_setup
+):
+ attack_angle = medium_force_setup.calculate_attack_angle(apparent_velocity, orientation)
+ assert np.isclose(
+ attack_angle, expected_angle, atol=1e-7
+ ), f"Expected {expected_angle}, got {attack_angle}"
+
+
+# Test taken from https://www1.grc.nasa.gov/beginners-guide-to-aeronautics/foilsimstudent/
+@pytest.mark.parametrize(
+ "orientation, expected_lift, expected_drag, apparent_velocity",
+ [
+ # Tests for attack angle 0
+ (0, 0, 140, np.array([44 * math.cos(0), 44 * math.sin(0)])),
+ # # # Tests for attack angle 5
+ (0, 6262, 509, np.array([44 * math.cos(np.deg2rad(5)), 44 * math.sin(np.deg2rad(5))])),
+ # # Tests for attack angle 10
+ (0, 11934, 1568, np.array([44 * math.cos(np.deg2rad(10)), 44 * math.sin(np.deg2rad(10))])),
+ # # Tests for attack angle 15
+ (0, 15162, 3035, np.array([44 * math.cos(np.deg2rad(15)), 44 * math.sin(np.deg2rad(15))])),
+ # Tests for attack angle 20
+ (
+ 0,
+ 11768,
+ 3249,
+ np.array([44 * math.cos(np.deg2rad(20)), 44 * math.sin(np.deg2rad(20))]),
+ ),
+ ],
+)
+def test_compute_forces(
+ medium_force_setup, orientation, expected_lift, expected_drag, apparent_velocity
+):
+ lift_force, drag_force = medium_force_setup.compute(apparent_velocity, orientation)
+ assert np.isclose(
+ np.linalg.norm(lift_force), expected_lift, rtol=0.05
+ ), f"Expected {expected_lift}, got {np.linalg.norm(lift_force)}"
+ assert np.isclose(
+ np.linalg.norm(drag_force), expected_drag, rtol=0.05
+ ), f"Expected {expected_drag}, got {np.linalg.norm(drag_force)}"
diff --git a/src/controller/README.md b/src/controller/README.md
index 05c831a2c..899411661 100644
--- a/src/controller/README.md
+++ b/src/controller/README.md
@@ -1,7 +1,5 @@
# Controller
-[](https://github.com/UBCSailbot/controller/actions/workflows/tests.yml)
-
UBC Sailbot's controller for the new project. This repository contains a ROS package `controller`. This README
contains only setup and run instructions. Further information on the controller can be found on the software
team's [docs website](https://ubcsailbot.github.io/sailbot_workspace/main/current/controller/overview/).
diff --git a/src/controller/tests/unit/wingsail/common/test_lut.py b/src/controller/tests/unit/wingsail/common/test_lut.py
index dc7c1d907..f0c3436e4 100644
--- a/src/controller/tests/unit/wingsail/common/test_lut.py
+++ b/src/controller/tests/unit/wingsail/common/test_lut.py
@@ -30,7 +30,7 @@ def test_unknown_interpolation_exception(self):
[
[[10000, 10000, 10000], [1, 1, 1]],
[10000, 10000, 10000],
- [[0, 1], 10000, 10000],
+ np.array([[0, 1], 10000, 10000], dtype=object),
np.array([[10000, 10000, 10000], [1, 1, 1]]),
np.array([10000, 10000, 10000]),
np.array([[[0, 1]], [[0, 1]], [[0, 1]]]),
diff --git a/src/custom_interfaces/README.md b/src/custom_interfaces/README.md
index b918e7bb0..a467ebe41 100644
--- a/src/custom_interfaces/README.md
+++ b/src/custom_interfaces/README.md
@@ -19,7 +19,7 @@ ROS messages and services used across many ROS packages in the project.
Update diagram by editing diagrams/src/external_interfaces.puml and the PlantUML Export Diagram command in VSCode
--->
-
+
### Project-wide Internal Interfaces
@@ -36,7 +36,7 @@ Update diagram by editing diagrams/src/external_interfaces.puml and the PlantUML
## Boat Simulator Interfaces
-ROS messages and services used in our [boat simulator](https://github.com/UBCSailbot/boat_simulator).
+ROS messages and services used in our [boat simulator](https://github.com/UBCSailbot/sailbot_workspace/tree/main/src/boat_simulator).
### Boat Simulator External Interfaces
diff --git a/src/custom_interfaces/diagrams/out/external_interfaces.png b/src/custom_interfaces/diagrams/out/external_interfaces.png
index 5634cb399..b20a87a3a 100644
Binary files a/src/custom_interfaces/diagrams/out/external_interfaces.png and b/src/custom_interfaces/diagrams/out/external_interfaces.png differ
diff --git a/src/global_launch/config/README.md b/src/global_launch/config/README.md
index b2daac721..60065e737 100644
--- a/src/global_launch/config/README.md
+++ b/src/global_launch/config/README.md
@@ -73,6 +73,24 @@ ROS parameters specific to the nodes in the local_pathfinding package.
- _Acceptable Values_: `"bitstar"`, `"bfmtstar"`, `"fmtstar"`, `"informedrrtstar"`, `"lazylbtrrt"`, `"lazyprmstar"`,
`"lbtrrt"`, `"prmstar"`, `"rrtconnect"`, `"rrtsharp"`, `"rrtstar"`, `"rrtxstatic"`, `"sorrtstar"`
+## Controller Parameters
+
+ROS parameters specific to the nodes in the Controller.
+
+### wingsail_ctrl_node
+
+**`reynolds_number`**
+
+- _Description_: The Reynolds number of the wind.
+- _Datatype_: `double`
+- _Range_: `(0.0, MAX_DOUBLE)`
+
+**`angle_of_attack`**
+
+- _Description_: The angle of attack of the sail.
+- _Datatype_: `double`
+- _Range_: `(-180.0, 180.0]`
+
## Boat Simulator Parameters
ROS parameters specific to the nodes in the boat simulator.
diff --git a/src/global_launch/config/globals.yaml b/src/global_launch/config/globals.yaml
index b385741dd..7cec8befa 100644
--- a/src/global_launch/config/globals.yaml
+++ b/src/global_launch/config/globals.yaml
@@ -16,6 +16,12 @@ navigate_main:
ros__parameters:
path_planner: "rrtstar"
+# controller parameters
+wingsail_ctrl_node:
+ ros__parameters:
+ reynolds_number: [0.0, 1.0, 2.0]
+ angle_of_attack: [0.0, 1.0, 2.0]
+
# boat_simulator parameters
low_level_control_node:
ros__parameters:
diff --git a/src/integration_tests/custom-rosdep.yaml b/src/integration_tests/custom-rosdep.yaml
new file mode 100644
index 000000000..0be358f82
--- /dev/null
+++ b/src/integration_tests/custom-rosdep.yaml
@@ -0,0 +1,4 @@
+python3-pyyaml-types-pip:
+ ubuntu:
+ pip:
+ packages: [types-PyYAML]
diff --git a/src/integration_tests/package.xml b/src/integration_tests/package.xml
index f15345d2f..fa7ddc0f2 100644
--- a/src/integration_tests/package.xml
+++ b/src/integration_tests/package.xml
@@ -7,9 +7,13 @@
Henry Huang
MIT
+
rclpy
custom_interfaces
+
+ python3-pyyaml-types-pip
+
ament_python
diff --git a/src/local_pathfinding/README.md b/src/local_pathfinding/README.md
index b92bfcdc3..333b45d71 100644
--- a/src/local_pathfinding/README.md
+++ b/src/local_pathfinding/README.md
@@ -1,7 +1,5 @@
# Local Pathfinding
-[](https://github.com/UBCSailbot/local_pathfinding/actions/workflows/tests.yml)
-
UBC Sailbot's local pathfinding ROS package
## Run
diff --git a/src/local_pathfinding/test/test_local_path.py b/src/local_pathfinding/test/test_local_path.py
index 001c45f3c..0d5d2c754 100644
--- a/src/local_pathfinding/test/test_local_path.py
+++ b/src/local_pathfinding/test/test_local_path.py
@@ -12,7 +12,7 @@ def test_LocalPath_update_if_needed():
ais_ships=AISShips(),
global_path=Path(),
filtered_wind_sensor=WindSensor(),
- planner="bitstar",
+ planner="rrtstar",
)
assert PATH.waypoints is not None, "waypoints is not initialized"
assert len(PATH.waypoints) > 1, "waypoints length <= 1"
diff --git a/src/local_pathfinding/test/test_objectives.py b/src/local_pathfinding/test/test_objectives.py
index 9289b650f..da31d4e7c 100644
--- a/src/local_pathfinding/test/test_objectives.py
+++ b/src/local_pathfinding/test/test_objectives.py
@@ -22,7 +22,7 @@
ais_ships=AISShips(),
global_path=Path(),
filtered_wind_sensor=WindSensor(),
- planner="bitstar",
+ planner="rrtstar",
),
)
diff --git a/src/local_pathfinding/test/test_ompl_path.py b/src/local_pathfinding/test/test_ompl_path.py
index e7662566d..73013c310 100644
--- a/src/local_pathfinding/test/test_ompl_path.py
+++ b/src/local_pathfinding/test/test_ompl_path.py
@@ -15,7 +15,7 @@
ais_ships=AISShips(),
global_path=Path(),
filtered_wind_sensor=WindSensor(),
- planner="bitstar",
+ planner="rrtstar",
),
)
diff --git a/src/network_systems/.gitignore b/src/network_systems/.gitignore
index ec6f8109d..38919a42c 100644
--- a/src/network_systems/.gitignore
+++ b/src/network_systems/.gitignore
@@ -1,5 +1,6 @@
# autogenerated files
/lib/cmn_hdrs/ros_info.h
+/launch/ros_info.py
*.pyc
# PlantUML diagram export directory
diff --git a/src/network_systems/README.md b/src/network_systems/README.md
index eb0bb74e0..c0d44c3ac 100755
--- a/src/network_systems/README.md
+++ b/src/network_systems/README.md
@@ -1,7 +1,5 @@
# Network Systems
-[](https://github.com/UBCSailbot/network_systems/actions/workflows/tests.yml)
-
This repository contains the source code for all of UBC Sailbot's Network Systems programs. It is made to work as part
of [Sailbot Workspace](https://github.com/UBCSailbot/sailbot_workspace), and is **_not_** meant to be built as an
independent project.
diff --git a/src/network_systems/config/local_transceiver/local_transceiver_template.yaml b/src/network_systems/config/local_transceiver/local_transceiver_template.yaml
new file mode 100644
index 000000000..fd1382e84
--- /dev/null
+++ b/src/network_systems/config/local_transceiver/local_transceiver_template.yaml
@@ -0,0 +1,6 @@
+# Template for the local_transceiver module
+local_transceiver_node:
+ ros__parameters:
+ enabled: true
+ # The following parameters are optional. Defaults are set in local_transceiver_ros_intf.cpp
+ port: # String: Serial port that the Local Transceiver will use (default: /tmp/local_transceiver_test_port)
diff --git a/src/network_systems/launch/main_launch.py b/src/network_systems/launch/main_launch.py
index 101a9ffe3..9241dc518 100644
--- a/src/network_systems/launch/main_launch.py
+++ b/src/network_systems/launch/main_launch.py
@@ -1,6 +1,7 @@
"""Launch file that runs all nodes for the network systems ROS package."""
import os
+import sys
from importlib.util import module_from_spec, spec_from_file_location
from typing import List, Tuple
@@ -12,6 +13,16 @@
from launch.some_substitutions_type import SomeSubstitutionsType
from launch.substitutions import LaunchConfiguration
+# Deal with Python import paths
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(SCRIPT_DIR)
+from ros_info import ( # noqa: E402
+ CACHED_FIB_NODE,
+ CAN_TRANSCEIVER_NODE,
+ MOCK_AIS_NODE,
+ REMOTE_TRANSCEIVER_NODE,
+)
+
# Local launch arguments and constants
PACKAGE_NAME = "network_systems"
NAMESPACE = ""
@@ -75,6 +86,7 @@ def setup_launch(context: LaunchContext) -> List[Node]:
launch_description_entities.append(get_mock_ais_description(context))
launch_description_entities.append(get_can_transceiver_description(context))
launch_description_entities.append(get_remote_transceiver_description(context))
+ launch_description_entities.append(get_local_transceiver_description(context))
return launch_description_entities
@@ -87,7 +99,7 @@ def get_cached_fib_description(context: LaunchContext) -> Node:
Returns:
Node: The node object that launches the cached_fib_node.
"""
- node_name = "cached_fib_node"
+ node_name = CACHED_FIB_NODE
ros_parameters = [
global_launch_config,
{"mode": LaunchConfiguration("mode")},
@@ -119,7 +131,7 @@ def get_mock_ais_description(context: LaunchContext) -> Node:
Returns:
Node: The node object that launches the mock_ais_node.
"""
- node_name = "mock_ais_node"
+ node_name = MOCK_AIS_NODE
ros_parameters = [
global_launch_config,
{"mode": LaunchConfiguration("mode")},
@@ -151,7 +163,7 @@ def get_can_transceiver_description(context: LaunchContext) -> Node:
Returns:
Node: The node object that launches the can_transceiver_node.
"""
- node_name = "can_transceiver_node"
+ node_name = CAN_TRANSCEIVER_NODE
ros_parameters = [
global_launch_config,
{"mode": LaunchConfiguration("mode")},
@@ -183,7 +195,7 @@ def get_remote_transceiver_description(context: LaunchContext) -> Node:
Returns:
Node: The node object that launches the remote_transceiver_node.
"""
- node_name = "remote_transceiver_node"
+ node_name = REMOTE_TRANSCEIVER_NODE
ros_parameters = [
global_launch_config,
{"mode": LaunchConfiguration("mode")},
@@ -204,3 +216,34 @@ def get_remote_transceiver_description(context: LaunchContext) -> Node:
)
return node
+
+
+def get_local_transceiver_description(context: LaunchContext) -> Node:
+ """Gets the launch description for the local_transceiver_node.
+
+ Args:
+ context (LaunchContext): The current launch context.
+
+ Returns:
+ Node: The node object that launches the local_transceiver_node.
+ """
+ node_name = "local_transceiver_node"
+ ros_parameters = [
+ global_launch_config,
+ {"mode": LaunchConfiguration("mode")},
+ *LaunchConfiguration("config").perform(context).split(","),
+ ]
+ ros_arguments: List[SomeSubstitutionsType] = [
+ "--log-level",
+ [f"{node_name}:=", LaunchConfiguration("log_level")],
+ ]
+ node = Node(
+ package=PACKAGE_NAME,
+ namespace=NAMESPACE,
+ executable="local_transceiver",
+ name=node_name,
+ parameters=ros_parameters,
+ ros_arguments=ros_arguments,
+ )
+
+ return node
diff --git a/src/network_systems/lib/cmn_hdrs/CMakeLists.txt b/src/network_systems/lib/cmn_hdrs/CMakeLists.txt
index bb66d729c..ade5a688c 100644
--- a/src/network_systems/lib/cmn_hdrs/CMakeLists.txt
+++ b/src/network_systems/lib/cmn_hdrs/CMakeLists.txt
@@ -1,8 +1,7 @@
# Generate ROS info header file
-set(ROS_INFO_FILE ${CMAKE_SOURCE_DIR}/ros_info.txt)
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/lib/cmn_hdrs/ros_info.h
- COMMAND ${CMAKE_SOURCE_DIR}/scripts/autogen_ros_topics.sh ${ROS_INFO_FILE}
+ COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/gen_ros_info.py
DEPENDS ${CMAKE_SOURCE_DIR} ${ROS_INFO_FILE}
)
add_custom_target(ros_info_h DEPENDS ${CMAKE_CURRENT_LIST_DIR}/ros_info.h)
diff --git a/src/network_systems/package.xml b/src/network_systems/package.xml
index c0c2c28fd..f57ddba19 100755
--- a/src/network_systems/package.xml
+++ b/src/network_systems/package.xml
@@ -8,11 +8,19 @@
Henry Huang
Apache License 2.0
+
ament_cmake
- rclcpp
- std_msgs
custom_interfaces
+ rclcpp
ros2launch
+ std_msgs
+
+
+ boost
+
+ protobuf-dev
+
+ gtest
ament_cmake
diff --git a/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h b/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h
index 9b6c935e5..6cf9992a6 100644
--- a/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h
+++ b/src/network_systems/projects/can_transceiver/inc/can_frame_parser.h
@@ -5,6 +5,9 @@
#include
#include
+#include
+#include
+#include
#include