diff --git a/.env b/.env new file mode 100644 index 0000000..7ed9971 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +REPO=spanny diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..949a854 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: [push, pull_request, workflow_dispatch] + +jobs: + build: + name: ${{matrix.platform.name}} ${{matrix.type.name}} ${{matrix.config.name}} + runs-on: ${{matrix.platform.os}} + + strategy: + fail-fast: false + matrix: + platform: + - { name: Linux, os: ubuntu-latest } + - { name: macOS, os: macos-latest } + type: + - { name: Shared, flags: "ON" } + - { name: Static, flags: "OFF" } + config: + - { name: Debug } + - { name: Release } + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure + run: cmake -B build -DBUILD_SHARED_LIBS=${{matrix.type.flags}} -DCMAKE_BUILD_TYPE=${{matrix.config.name}} + + - name: Build + run: cmake --build build --config ${{matrix.config.name}} + + - name: Test + run: ctest --test-dir build --output-on-failure --build-config ${{matrix.config.name}} diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..89b2328 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,90 @@ +name: docker + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + push: + paths: + - 'Dockerfile' + - '.github/workflows/docker.yaml' + +jobs: + hadolint: + name: hadolint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: hadolint/hadolint-action@v3.0.0 + with: + dockerfile: Dockerfile + verbose: true + - name: Update Pull Request + uses: actions/github-script@v6 + if: github.event_name == 'pull_request' + with: + script: | + const output = ` + #### Hadolint: \`${{ steps.hadolint.outcome }}\` + \`\`\` + ${process.env.HADOLINT_RESULTS} + \`\`\` + `; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + upstream: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Github Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: Dockerfile + push: true + target: upstream + tags: ghcr.io/griswaldbrooks/spanny:upstream + cache-from: type=gha + cache-to: type=gha,mode=max + development: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Github Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: Dockerfile + push: true + target: development + build-args: | + USER=ci-user + UID=1000 + GID=1000 + tags: ghcr.io/griswaldbrooks/spanny:ci-user-development + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 259148f..b7d3013 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,8 @@ -# Prerequisites -*.d - -# Compiled Object files -*.slo -*.lo -*.o -*.obj - -# Precompiled Headers -*.gch -*.pch - -# Compiled Dynamic libraries -*.so -*.dylib -*.dll - -# Fortran module files -*.mod -*.smod - -# Compiled Static libraries -*.lai -*.la +doc/html +doc/manifest.yaml *.a -*.lib - -# Executables -*.exe -*.out -*.app +Makefile +CMakeCache.txt +CMakeFiles/ +filter_tests +build diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..a0b5294 --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,4 @@ +ignored: + - DL3007 + - DL3008 + - SC1091 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..10f423c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.16) +project(spanny CXX) + +if(CMAKE_CXX_COMPILER_ID MATCHES "(GNU|Clang)") + add_compile_options(-Werror -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion) +endif() + +add_library(spanny INTERFACE) +add_library(spanny::spanny ALIAS spanny) +target_include_directories(spanny INTERFACE $) +target_compile_features(spanny INTERFACE cxx_std_17) + +if(NOT PROJECT_IS_TOP_LEVEL) + return() +endif() + +include(CTest) +if(BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c7c098c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1 +FROM ubuntu:22.04 as upstream + +# Prevent the interactive wizards from stopping the build +ARG DEBIAN_FRONTEND=noninteractive + +# Get the basics +# hadolint ignore=DL3008 +RUN --mount=type=cache,target=/var/cache/apt,id=apt \ + apt-get update -y && apt-get install -q -y --no-install-recommends \ + build-essential \ + cmake \ + lsb-core \ + wget \ + && rm -rf /var/lib/apt/lists/* + +FROM upstream AS development + +ARG UID +ARG GID +ARG USER + +# fail build if args are missing +# hadolint ignore=SC2028 +RUN if [ -z "$UID" ]; then echo '\nERROR: UID not set. Run \n\n \texport UID=$(id -u) \n\n on host before building Dockerfile.\n'; exit 1; fi +# hadolint ignore=SC2028 +RUN if [ -z "$GID" ]; then echo '\nERROR: GID not set. Run \n\n \texport GID=$(id -g) \n\n on host before building Dockerfile.\n'; exit 1; fi +# hadolint ignore=SC2028 +RUN if [ -z "$USER" ]; then echo '\nERROR: USER not set. Run \n\n \texport USER=$(whoami) \n\n on host before building Dockerfile.\n'; exit 1; fi +# hadolint ignore=DL3008 +RUN --mount=type=cache,target=/var/cache/apt,id=apt \ + apt-get update && apt-get upgrade -y \ + && apt-get install -q -y --no-install-recommends \ + git \ + neovim \ + python3 \ + python3-pip \ + sudo \ + ssh \ + vim \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# install developer tools +RUN python3 -m pip install --no-cache-dir \ + pre-commit==3.0.4 + +# install hadolint +RUN wget -q -O /bin/hadolint https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64 \ + && chmod +x /bin/hadolint + +# Setup user home directory +# --no-log-init helps with excessively long UIDs +RUN groupadd --gid $GID $USER \ + && useradd --no-log-init --uid $GID --gid $UID -m $USER --groups sudo \ + && echo $USER ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USER \ + && chmod 0440 /etc/sudoers.d/$USER \ + && echo "source /opt/ros/${ROS_DISTRO}/setup.bash" >> /home/${USER}/.profile \ + && touch /home/${USER}/.bashrc \ + && chown -R ${GID}:${UID} /home/${USER} + +USER $USER +ENV SHELL /bin/bash +ENTRYPOINT [] + +# Setup mixin +WORKDIR /home/${USER}/ws diff --git a/README.md b/README.md index 43c147a..137a880 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# spanny \ No newline at end of file +# spanny +Robot arm project for lightening talk on mdspan. +Presented at CppCon 2023. + +# Development Container +Build a new development image +```shell +mkdir -p ~/.spanny/ccache +export UID=$(id -u) export GID=$(id -g); docker compose -f compose.dev.yml build +``` +Start an interactive development container +```shell +docker compose -f compose.dev.yml run development +``` +Build the repository in the container +```shell +username@spanny-dev:~/ws$ cmake -S src/filter/ -B build +username@spanny-dev:~/ws$ cmake --build build +``` + +# Test +```shell +username@spanny-dev:~/ws$ ctest --test-dir build +``` diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..aa1ced0 --- /dev/null +++ b/compose.dev.yml @@ -0,0 +1,31 @@ +version: '3.8' + +services: + development: + build: + args: + UID: ${UID} + GID: ${GID} + USER: ${USER} + context: . + dockerfile: Dockerfile + command: bash -c "export PATH="/usr/lib/ccache:$PATH" && + bash" + container_name: ${USER}-${REPO}-dev + environment: + - TERM=xterm-256color + extra_hosts: + - ${REPO}-dev:127.0.0.1 + hostname: ${REPO}-dev + image: ${REPO}-dev:latest + network_mode: host + privileged: true + volumes: + - ~/.ssh:${HOME}/.ssh:ro + - ~/.gitconfig:${HOME}/.gitconfig:ro + - ${PWD}:${HOME}/ws/src/${REPO} + - ~/.${REPO}/ccache:${HOME}/.ccache + - /etc/group:/etc/group:ro + - /etc/passwd:/etc/passwd:ro + - /etc/shadow:/etc/shadow:ro + working_dir: ${HOME}/ws diff --git a/include/filter/linear_filter.hpp b/include/filter/linear_filter.hpp new file mode 100644 index 0000000..f828e9f --- /dev/null +++ b/include/filter/linear_filter.hpp @@ -0,0 +1,74 @@ +#ifndef FILTER_LINEAR_FILTER_H_ +#define FILTER_LINEAR_FILTER_H_ + +#include // For deque +#include // For inner_product +#include // For domain_error, length_error +#include // For move +#include // For vector + +namespace gb::filter { + +/// \tparam Coefficients number of filter elements +template +class linear_filter { + public: + /// \brief Constructor for linear_filter. + /// \param b Feedforward coefficients for the input samples. + /// \param a Feedback coefficients for the output samples. + /// \note The number of coefficients is the order of the filter + 1. + /// \throws if \p b and \p a are of different lengths. + /// \throws if the first element of \p a is not 1. + linear_filter(std::vector b, std::vector a) + : b_{std::move(b)}, + a_{std::move(a)}, + x_(b_.size(), T{}), + y_(a_.size(), T{}) { + if (b_.size() != a_.size()) { + throw std::length_error{"Coefficients must have same length"}; + } + if (a_.front() != 1.) { + throw std::domain_error{"First element of output coefficients must be 1"}; + } + } + + // \brief Finite impulse response constructor + linear_filter(std::vector b) + : b_{std::move(b)}, + x_(b_.size(), T{}) {} + + /// \brief Filters value + /// \param value Input to be filtered. + /// \returns filtered value. + T& operator()(const T& value) { + // Remove the oldest elements. + x_.pop_back(); + y_.pop_back(); + + // Add the new input pose to the vector of inputs. + x_.emplace_front(value); + + // Update the new output with the rest of the values + const auto bx = + std::inner_product(b_.cbegin(), b_.cend(), x_.cbegin(), T{}); + // Skip the first coefficient since it's always 1, which allows us to avoid + // zero initializing the new output. + const auto ay = + std::inner_product(a_.cbegin() + 1, a_.cend(), y_.cbegin(), T{}); + + // Initialize the new output and return + return y_.emplace_front(bx - ay); + } + + private: + /// \brief Input coefficients + std::vector b_; + /// \brief Output coefficients + std::vector a_; + /// \brief Input taps + std::deque x_; + /// \brief Previous output + std::deque y_; +}; +} // namespace gb::filter +#endif // FILTER_LINEAR_FILTER_H_ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..6ba4c9b --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,13 @@ +list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}) + +find_package(GTest 1.13.0 REQUIRED) +find_package(Threads REQUIRED) + +add_executable(test_filter linear_filter_tests.cpp) +target_link_libraries(test_filter PRIVATE + spanny::spanny + GTest::gtest + GTest::gmock + Threads::Threads +) +gtest_discover_tests(test_filter) diff --git a/test/FindGTest.cmake b/test/FindGTest.cmake new file mode 100644 index 0000000..08708f3 --- /dev/null +++ b/test/FindGTest.cmake @@ -0,0 +1,10 @@ +include(FetchContent) + +set(INSTALL_GTEST OFF) +FetchContent_Declare(GTest + GIT_REPOSITORY https://github.com/google/googletest + GIT_TAG v${GTest_FIND_VERSION}) +FetchContent_MakeAvailable(GTest) +set_target_properties(gtest PROPERTIES COMPILE_OPTIONS "") +set_target_properties(gmock PROPERTIES COMPILE_OPTIONS "") +include(GoogleTest) diff --git a/test/linear_filter_tests.cpp b/test/linear_filter_tests.cpp new file mode 100644 index 0000000..e91ba11 --- /dev/null +++ b/test/linear_filter_tests.cpp @@ -0,0 +1,143 @@ +#include "filter/linear_filter.hpp" // For linear_filter + +#include // For transform +#include // For hypot +#include // For get +#include // For vector + +#include "gmock/gmock.h" // For EXPECT_* +#include "gtest/gtest.h" // For TEST_* + +using ::testing::DoubleNear; +using ::testing::Pointwise; +using ::testing::TestWithParam; + +namespace { +/// \brief Acceptable error between doubles +constexpr auto tolerance = 1e-4; +/// \brief Random test samples +auto const test_samples = + std::vector{0.447265, 0.677158, 0.490548, 0.896610, 0.948445, + 0.748019, 0.050977, 0.457688, 0.448624, 0.810386}; +} // namespace + +namespace gb::filter::test_filter { + +struct point { + double x, y, z; +}; + +point operator+(point const& lhs, point const& rhs) { + return point{lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z}; +} + +point operator-(point const& lhs, point const& rhs) { + return point{lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z}; +} + +point operator*(double s, point const& p) { + return point{s * p.x, s * p.y, s * p.z}; +} + +/// \brief Compare points using euclidean distance +MATCHER_P(NearWithPrecision, precision, "") { + auto const lhs = std::get<0>(arg); + auto const rhs = std::get<1>(arg); + return std::hypot(lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z) < precision; +} + +/// \brief Create points from doubles +/// \param values Scalars to use for each point +/// \returns points with x == y == z == value +std::vector make_points(std::vector const& values) { + std::vector points(values.size()); + std::transform(values.cbegin(), values.cend(), points.begin(), + [](auto const& value) { + point p; + p.x = p.y = p.z = value; + return p; + }); + return points; +} + +/// \brief Filter configuration to test +template +struct scenario { + std::string display; + std::vector b; // Input coefficients + std::vector a; // Output coefficients + std::vector samples; // Input to filter + std::vector expected; // Expected filtered values +}; + +/// \brief Insertion operator for verbose test output +template +std::ostream& operator<<(std::ostream& os, const scenario& s) { + return os << s.display; +} + +const auto scenarios = ::testing::Values( + // First order, 0.2 rad/sec (normalized cutoff frequency). + scenario{"first order low pass filter", + {0.24524, 0.24524}, + {1.00000, -0.50953}, + test_samples, + {0.10969, 0.33164, 0.45534, 0.57219, 0.74402, 0.79513, + 0.60108, 0.43101, 0.44187, 0.53390}}, + // Second order, 0.5 rad/sec (normalized cutoff frequency). + scenario{"second order low pass filter", + {0.29289, 0.58579, 0.29289}, + {1.0000e+00, -1.3878e-16, 1.7157e-01}, + test_samples, + {0.13100, 0.46034, 0.64887, 0.66932, 0.83536, 0.92245, + 0.58758, 0.22474, 0.31363, 0.59565}}, + // Third order, 0.7 rad/sec (normalized cutoff frequency). + scenario{"third order low pass filter", + {0.37445, 1.12336, 1.12336, 0.37445}, + {1.00000, 1.16192, 0.69594, 0.13776}, + test_samples, + {0.16748, 0.56140, 0.67795, 0.61346, 0.90503, 0.96453, + 0.42549, 0.13377, 0.43508, 0.68341}}); + +/// \brief Fixture for testing linear_filter +struct LinearFilterContext : public TestWithParam> {}; + +TEST_P(LinearFilterContext, FilterValues) { + // GIVEN a filter + const auto [_, b, a, samples, expected] = GetParam(); + auto filter = linear_filter{b, a}; + + // WHEN the inputs are filtered + auto result = std::vector(samples.size()); + std::transform(samples.cbegin(), samples.cend(), result.begin(), filter); + + // THEN they should match within tolerance. + EXPECT_THAT(result, Pointwise(DoubleNear(tolerance), expected)); +} + +INSTANTIATE_TEST_SUITE_P(LinearFilterDouble, LinearFilterContext, scenarios); + +TEST_F(LinearFilterContext, FilterPoints) { + // GIVEN a first order Point filter (0.2 rad/sec (normalized cutoff frequency) + const auto b = std::vector{0.24524, 0.24524}; + const auto a = std::vector{1.00000, -0.50953}; + auto filter = linear_filter{b, a}; + + // WHEN the inputs are filtered + const auto samples = make_points(test_samples); + auto result = std::vector(samples.size()); + std::transform(samples.cbegin(), samples.cend(), result.begin(), filter); + + // THEN they should match within tolerance. + const auto expected = + make_points({0.10969, 0.33164, 0.45534, 0.57219, 0.74402, 0.79513, + 0.60108, 0.43101, 0.44187, 0.53390}); + EXPECT_THAT(result, Pointwise(NearWithPrecision(tolerance), expected)); +} + +} // namespace gb::filter::test_filter + +int main(int argc, char **argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}