diff --git a/.github/workflows/configure_opensim_env.sh b/.github/workflows/configure_opensim_env.sh new file mode 100755 index 00000000..a392e8d5 --- /dev/null +++ b/.github/workflows/configure_opensim_env.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# +# Point a conda environment at a locally built OpenSim, so that +# `import opensim` works without manually exporting PYTHONPATH/LD_LIBRARY_PATH +# every time. +# +# It writes conda "activate.d"/"deactivate.d" hooks into the CURRENTLY ACTIVE +# conda environment. Those hooks export PYTHONPATH/LD_LIBRARY_PATH +# automatically every time you `conda activate `, and unset them again +# on `conda deactivate` -- no manual exporting needed after this. +# +# --- Usage --- +# +# 1. Build OpenSim first (only needs to be done once, or again after pulling +# changes to install_opensim.sh), e.g.: +# +# conda activate biobuddy +# bash .github/workflows/install_opensim.sh "$CONDA_PREFIX" +# +# This creates ./opensim/opensim_core_install relative to wherever you ran +# the build command from. +# +# 2. With that SAME conda env still active, run this script from the same +# directory, pointing it at the install directory if it isn't the default: +# +# bash .github/workflows/configure_opensim_env.sh [path/to/opensim_core_install] +# +# (defaults to ./opensim/opensim_core_install if no argument is given) +# +# 3. Re-activate the env so the hook actually runs, then test: +# +# conda deactivate +# conda activate biobuddy +# python -c "import opensim; print(opensim.GetVersion())" +# +# Re-run this script any time OpenSim gets rebuilt or moved to a new path. + +set -e + +OPENSIM_INSTALL="${1:-$(pwd)/opensim/opensim_core_install}" + +if [ -z "$CONDA_PREFIX" ]; then + echo "Error: no conda environment is active. Run 'conda activate ' first." >&2 + exit 1 +fi + +if [ ! -d "$OPENSIM_INSTALL" ]; then + echo "Error: OpenSim install directory not found: $OPENSIM_INSTALL" >&2 + echo "Pass the path explicitly, e.g.: bash $0 /path/to/opensim_core_install" >&2 + exit 1 +fi + +# Find the "opensim" python package directory (the folder containing +# __init__.py) so we can put its PARENT on PYTHONPATH. +OPENSIM_PY_INIT=$(find "$OPENSIM_INSTALL" -path "*/opensim/__init__.py" | head -n1) +if [ -z "$OPENSIM_PY_INIT" ]; then + echo "Error: could not find an 'opensim/__init__.py' under $OPENSIM_INSTALL" >&2 + echo "Did the build finish and install successfully (with BUILD_PYTHON_WRAPPING=on)?" >&2 + exit 1 +fi +OPENSIM_PY_PKG_PARENT=$(dirname "$(dirname "$OPENSIM_PY_INIT")") + +# Collect every directory containing a compiled shared library (OpenSim's own +# libs plus the bundled Simbody ones), so the compiled extension modules can +# resolve them at runtime. +LIB_DIRS=$(find "$OPENSIM_INSTALL" -name "*.so*" -exec dirname {} \; | sort -u | paste -sd:) + +ACTIVATE_DIR="$CONDA_PREFIX/etc/conda/activate.d" +DEACTIVATE_DIR="$CONDA_PREFIX/etc/conda/deactivate.d" +mkdir -p "$ACTIVATE_DIR" "$DEACTIVATE_DIR" + +cat > "$ACTIVATE_DIR/opensim.sh" < "$DEACTIVATE_DIR/opensim.sh" <<'EOF' +# Auto-generated by configure_opensim_env.sh +export PYTHONPATH="$_OLD_PYTHONPATH" +export LD_LIBRARY_PATH="$_OLD_LD_LIBRARY_PATH" +unset _OLD_PYTHONPATH _OLD_LD_LIBRARY_PATH +EOF + +echo "Done." +echo " OpenSim python package dir: $OPENSIM_PY_PKG_PARENT" +echo " Library dirs: $LIB_DIRS" +echo "" +echo "Now run this to make it take effect:" +echo " conda deactivate && conda activate $(basename "$CONDA_PREFIX")" +echo ' python -c "import opensim; print(opensim.GetVersion())"' diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml deleted file mode 100644 index ef1f8d77..00000000 --- a/.github/workflows/copilot-setup-steps.yml +++ /dev/null @@ -1,62 +0,0 @@ -# This file was generated based on the recommendations from https://docs.github.com/en/enterprise-cloud@latest/copilot/customizing-copilot/customizing-the-development-environment-for-copilot-coding-agent#preinstalling-tools-or-dependencies-in-copilots-environment - -name: "Copilot Setup Steps" - -# Automatically run the setup steps when they are changed to allow for easy validation, and -# allow manual testing through the repository's "Actions" tab -on: - workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml - pull_request: - paths: - - .github/workflows/copilot-setup-steps.yml - -env: - EXAMPLES_FOLDER: examples - -jobs: - # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. - copilot-setup-steps: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - steps: - - name: Checkout code with submodule - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 - - - name: Setup conda - uses: conda-incubator/setup-miniconda@v3 - with: - miniforge-version: latest - activate-environment: biobuddy - python-version: 3.11.11 - - - name: Install dependencies - run: | - pip install . && pip uninstall -y biobuddy - pip install scipy==1.15.2 numpy==1.25.2 lxml ezc3d - - - name: Install test dependencies - run: | - pip install pytest pytest-cov codecov black - conda install -c opensim-org opensim=4.5.1 - conda install -c conda-forge biorbd=1.11.2 deepdiff - - - name: Set environment variables for Copilot - run: | - echo "CONDA_PREFIX=$CONDA_PREFIX" >> $GITHUB_ENV - echo "PYTHONPATH=$CONDA_PREFIX/lib/python3.11/site-packages:$PYTHONPATH" >> $GITHUB_ENV - echo "PATH=$CONDA_PREFIX/bin:$PATH" >> $GITHUB_ENV - - - name: Verify environment setup - run: | - echo "Python location: $(which python)" - echo "Conda prefix: $CONDA_PREFIX" - echo "Python path: $PYTHONPATH" - python -c "import sys; print('Python sys.path:'); [print(f' {p}') for p in sys.path]" \ No newline at end of file diff --git a/.github/workflows/install_opensim.sh b/.github/workflows/install_opensim.sh new file mode 100644 index 00000000..f527b735 --- /dev/null +++ b/.github/workflows/install_opensim.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +# Exit when an error happens instead of continue. +set -e + +# Default values for flags. +DEBUG_TYPE="Release" +NUM_JOBS=${OPENSIM_BUILD_JOBS:-24} +MOCO="off" +ORG="nickbianco" +BRANCH="dc7e1f0a18905fcd17cbbee9f923a70b4cb9de99" +GENERATOR="Ninja" +PYTHON_ROOT_DIR=$1 +WORKING_DIR="$(pwd)/opensim" +if [ -d "$WORKING_DIR" ]; then + rm -rf "$WORKING_DIR" +fi +mkdir -p "$WORKING_DIR" + +# Get opensim-core. Fetch the pinned commit directly instead of cloning the +# whole repo and checking it out afterwards: if that commit is later dropped +# from whatever branch it was on (rebase/branch deletion upstream), a normal +# clone stops including it and `git checkout $BRANCH` fails with "unable to +# read tree", even though the commit object itself still exists and remains +# directly fetchable by SHA. +mkdir -p "$WORKING_DIR/opensim-core" +cd "$WORKING_DIR/opensim-core" +git init -q +git remote add origin https://github.com/$ORG/opensim-core.git +git fetch --depth 1 origin $BRANCH +git checkout -q FETCH_HEAD + +# Build opensim-core dependencies. +mkdir -p "$WORKING_DIR/opensim-core/dependencies/build" +cd "$WORKING_DIR/opensim-core/dependencies/build" +cmake "$WORKING_DIR/opensim-core/dependencies" -G"$GENERATOR" -DCMAKE_BUILD_TYPE=$DEBUG_TYPE -DCMAKE_INSTALL_PREFIX="$WORKING_DIR/opensim_dependencies_install/" -DSUPERBUILD_ezc3d=off -DOPENSIM_WITH_CASADI=$MOCO -DBUILD_PYTHON_WRAPPING=on -DPython3_ROOT_DIR="$PYTHON_ROOT_DIR" +cmake . -LAH + +# opensim-core's own dependencies/CMakeLists.txt pins Simbody to a raw commit +# on nickbianco's fork. That commit can become orphaned (unreachable from any +# branch) if the fork gets rebased/cleaned up upstream, which makes +# ExternalProject_Add's normal git clone fail with "reference is not a tree" / +# "unable to read tree", even though the commit object itself still exists +# and remains directly fetchable by SHA (same class of issue as the +# opensim-core checkout above). Work around it the same way: fetch Simbody by +# SHA ourselves into the exact source dir ExternalProject expects, then mark +# its git-clone step as already done via the stamp file it checks, so the +# build skips straight to configuring/building the pre-fetched source. +SIMBODY_GIT_URL="https://github.com/nickbianco/simbody.git" +SIMBODY_GIT_TAG="6cd18d8b8466b3e574377a6b5acd942af78c7a88" +SIMBODY_SOURCE_DIR="$WORKING_DIR/opensim-core/dependencies/simbody" +SIMBODY_STAMP_DIR="$WORKING_DIR/opensim-core/dependencies/build/simbody/stamp" + +rm -rf "$SIMBODY_SOURCE_DIR" +mkdir -p "$SIMBODY_SOURCE_DIR" +( + cd "$SIMBODY_SOURCE_DIR" + git init -q + git remote add origin "$SIMBODY_GIT_URL" + git fetch --depth 1 origin "$SIMBODY_GIT_TAG" + git checkout -q FETCH_HEAD + if [ -f .gitmodules ]; then + git submodule update --init --recursive + fi +) +cp "$SIMBODY_STAMP_DIR/simbody-gitinfo.txt" "$SIMBODY_STAMP_DIR/simbody-gitclone-lastrun.txt" +touch "$SIMBODY_STAMP_DIR/simbody-gitclone-lastrun.txt" + +cmake --build . --config $DEBUG_TYPE -j$NUM_JOBS + + +# Build and install opensim-core. +mkdir -p "$WORKING_DIR/opensim-core/build" +cd "$WORKING_DIR/opensim-core/build" +cmake "$WORKING_DIR/opensim-core" -G"$GENERATOR" -DCMAKE_BUILD_TYPE=$DEBUG_TYPE -DOPENSIM_DEPENDENCIES_DIR="$WORKING_DIR/opensim_dependencies_install/" -DOPENSIM_C3D_PARSER=None -DBUILD_TESTING=off -DCMAKE_INSTALL_PREFIX="$WORKING_DIR/opensim_core_install" -DOPENSIM_INSTALL_UNIX_FHS=off -DOPENSIM_WITH_CASADI=$MOCO -DBUILD_PYTHON_WRAPPING=on -DPython3_ROOT_DIR="$PYTHON_ROOT_DIR" +cmake --build . --config $DEBUG_TYPE -j$NUM_JOBS +cmake --install . diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 86d9689a..c29cff0a 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -47,6 +47,18 @@ jobs: submodules: recursive fetch-depth: 0 + - name: Free disk space + run: | + # Building OpenSim from source (Simbody + the full OpenSim/Moco + # tree) plus conda/biorbd needs more disk than the ~14GB free by + # default on GitHub runners. Remove large preinstalled toolchains + # we don't use to make room, otherwise the build (or even the + # earlier `git clone` of opensim-core) can silently run out of + # space and fail with an unrelated-looking error. + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force || true + df -h / + - name: Setup conda uses: conda-incubator/setup-miniconda@v3 with: @@ -54,19 +66,44 @@ jobs: - name: Set up Python run: | - conda install -cconda-forge python=3.11.11 pip + conda install -cconda-forge python=3.11.11 pip + + - name: Install system dependencies for building OpenSim + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build \ + libblas-dev liblapack-dev \ + freeglut3-dev libxi-dev libxmu-dev \ + pkg-config + pip install swig - name: Install dependencies run: | pip install . && pip uninstall -y biobuddy pip install scipy==1.17.0 numpy==2.4.1 lxml ezc3d matplotlib plotly - conda install biorbd=1.11.2=py311h9439bbc_1 graphviz -cconda-forge + conda install biorbd graphviz -cconda-forge - name: Install test dependencies run: | pip install pytest pytest-cov codecov conda install -c conda-forge deepdiff pinocchio pandas - conda install -c opensim-org opensim=4.5.1 + + - name: Build and install OpenSim from source + run: | + # Cap parallel compile jobs to the runner's core count: the script + # defaults to 24 jobs, which OOM-kills the compiler on standard + # (4-core) GitHub runners partway through the build. + export OPENSIM_BUILD_JOBS=$(nproc) + bash .github/workflows/install_opensim.sh "$CONDA_PREFIX" + + OPENSIM_INSTALL="$(pwd)/opensim/opensim_core_install" + OPENSIM_PY_INIT=$(find "$OPENSIM_INSTALL" -path "*/opensim/__init__.py" | head -n1) + OPENSIM_PY_PKG_PARENT=$(dirname "$(dirname "$OPENSIM_PY_INIT")") + LIB_DIRS=$(find "$OPENSIM_INSTALL" -name "*.so*" -exec dirname {} \; | sort -u | paste -sd:) + + echo "PYTHONPATH=$OPENSIM_PY_PKG_PARENT:$PYTHONPATH" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$LIB_DIRS:$LD_LIBRARY_PATH" >> "$GITHUB_ENV" - name: List conda packages run: conda list @@ -102,4 +139,5 @@ jobs: pip install . cd python -c "import biobuddy" - cd $BASE_FOLDER \ No newline at end of file + cd $BASE_FOLDER + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 943a4d7f..ea13a153 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,5 @@ examples/data/hide_and_seek/ examples/models/hide_and_seek/ examples/data/arm26_allbiceps_1dof.png + +opensim/ diff --git a/biobuddy/components/functions.py b/biobuddy/components/functions.py index 5049d6eb..82d53959 100644 --- a/biobuddy/components/functions.py +++ b/biobuddy/components/functions.py @@ -114,10 +114,13 @@ def _calculate_coefficients(self): # Handle the case with only 2 points (linear interpolation) if self.nb_nodes == 2: - t = self.safe_max(self.x_points[1] - self.x_points[0]) - self.b[0] = self.b[1] = (self.y_points[1] - self.y_points[0]) / t - self.c[0] = self.c[1] = 0.0 - self.d[0] = self.d[1] = 0.0 + t = (self.safe_max(self.x_points[1] - self.x_points[0])).reshape(-1)[0] + self.b[0] = ((self.y_points[1] - self.y_points[0]) / t).reshape(-1)[0] + self.b[1] = self.b[0] + self.c[0] = 0.0 + self.c[1] = 0.0 + self.d[0] = 0.0 + self.d[1] = 0.0 return nm1 = self.nb_nodes - 1 @@ -125,13 +128,13 @@ def _calculate_coefficients(self): # Set up tridiagonal system: # b = diagonal, d = offdiagonal, c = right-hand side - self.d[0] = self.safe_max(self.x_points[1] - self.x_points[0]) - self.c[1] = (self.y_points[1] - self.y_points[0]) / self.d[0] + self.d[0] = (self.safe_max(self.x_points[1] - self.x_points[0])).reshape(-1)[0] + self.c[1] = ((self.y_points[1] - self.y_points[0]) / self.d[0]).reshape(-1)[0] for i in range(1, nm1): - self.d[i] = self.safe_max(self.x_points[i + 1] - self.x_points[i]) + self.d[i] = (self.safe_max(self.x_points[i + 1] - self.x_points[i])).reshape(-1)[0] self.b[i] = 2.0 * (self.d[i - 1] + self.d[i]) - self.c[i + 1] = (self.y_points[i + 1] - self.y_points[i]) / self.d[i] + self.c[i + 1] = ((self.y_points[i + 1] - self.y_points[i]) / self.d[i]).reshape(-1)[0] self.c[i] = self.c[i + 1] - self.c[i] # End conditions. Third derivatives at x[0] and x[self.nb_nodes-1] @@ -142,12 +145,12 @@ def _calculate_coefficients(self): self.c[nm1] = 0.0 if self.nb_nodes > 3: - d31 = self.safe_max(self.x_points[3] - self.x_points[1]) - d20 = self.safe_max(self.x_points[2] - self.x_points[0]) - d1 = self.safe_max(self.x_points[nm1] - self.x_points[self.nb_nodes - 3]) - d2 = self.safe_max(self.x_points[nm2] - self.x_points[self.nb_nodes - 4]) - d30 = self.safe_max(self.x_points[3] - self.x_points[0]) - d3 = self.safe_max(self.x_points[nm1] - self.x_points[self.nb_nodes - 4]) + d31 = (self.safe_max(self.x_points[3] - self.x_points[1])).reshape(-1)[0] + d20 = (self.safe_max(self.x_points[2] - self.x_points[0])).reshape(-1)[0] + d1 = (self.safe_max(self.x_points[nm1] - self.x_points[self.nb_nodes - 3])).reshape(-1)[0] + d2 = (self.safe_max(self.x_points[nm2] - self.x_points[self.nb_nodes - 4])).reshape(-1)[0] + d30 = (self.safe_max(self.x_points[3] - self.x_points[0])).reshape(-1)[0] + d3 = (self.safe_max(self.x_points[nm1] - self.x_points[self.nb_nodes - 4])).reshape(-1)[0] self.c[0] = self.c[2] / d31 - self.c[1] / d20 self.c[nm1] = self.c[nm2] / d1 - self.c[self.nb_nodes - 3] / d2 @@ -167,14 +170,14 @@ def _calculate_coefficients(self): self.c[i] = (self.c[i] - self.d[i] * self.c[i + 1]) / self.b[i] # Compute polynomial coefficients - self.b[nm1] = (self.y_points[nm1] - self.y_points[nm2]) / self.d[nm2] + self.d[nm2] * ( - self.c[nm2] + 2.0 * self.c[nm1] - ) + self.b[nm1] = ( + (self.y_points[nm1] - self.y_points[nm2]) / self.d[nm2] + self.d[nm2] * (self.c[nm2] + 2.0 * self.c[nm1]) + ).reshape(-1)[0] for i in range(nm1): - self.b[i] = (self.y_points[i + 1] - self.y_points[i]) / self.d[i] - self.d[i] * ( - self.c[i + 1] + 2.0 * self.c[i] - ) + self.b[i] = ( + (self.y_points[i + 1] - self.y_points[i]) / self.d[i] - self.d[i] * (self.c[i + 1] + 2.0 * self.c[i]) + ).reshape(-1)[0] self.d[i] = (self.c[i + 1] - self.c[i]) / self.d[i] self.c[i] *= 3.0 diff --git a/biobuddy/components/via_point_utils.py b/biobuddy/components/via_point_utils.py index dbafeae4..1fb2ca44 100644 --- a/biobuddy/components/via_point_utils.py +++ b/biobuddy/components/via_point_utils.py @@ -22,8 +22,8 @@ def __init__( def evaluate(self, angles: np.ndarray) -> np.ndarray: """Evaluate the condition based on the current joint angles.""" position = np.zeros((angles.shape[0],)) - for i_angle, angle in enumerate(angles): - position[i_angle] = self.locations[i_angle].evaluate(angle) + for i_angle in range(angles.shape[0]): + position[i_angle] = self.locations[i_angle].evaluate(angles.reshape(-1)[i_angle]).reshape(-1)[0] return position diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md index 64a00b66..1c391956 100644 --- a/docs/pull_request_template.md +++ b/docs/pull_request_template.md @@ -10,7 +10,7 @@ 1. [ ] Does your submission pass the tests (if not please explain why this is intended)? 2. [ ] Did you write a proper documentation (docstrings and ReadMe) -3. [ ] Have you linted your code locally prior to submission (using the command: `black . -l120"`)? +3. [ ] Have you linted your code locally prior to submission (using the command: `black . -l120 --exclude "external/*" --exclude "opensim/*"`)? ### Changes to Core Features: diff --git a/tests/test_joint_center_tool.py b/tests/test_joint_center_tool.py index 679dfa5a..0732988d 100644 --- a/tests/test_joint_center_tool.py +++ b/tests/test_joint_center_tool.py @@ -1034,19 +1034,17 @@ def test_get_svd(): """Test the get_svd function""" np.random.seed(42) - # Create simple test RT matrices + # Create test RT matrices rotating about all 3 axes nb_frames = 10 rt_parent = RotoTransMatrixTimeSeries(nb_frames) rt_child = RotoTransMatrixTimeSeries(nb_frames) for i in range(nb_frames): - # Parent rotates slightly around X rt_parent[i] = RotoTransMatrix.from_euler_angles_and_translation( - "xyz", np.array([i * 0.1, 0, 0]), np.array([0, 0, 0]) + "xyz", np.array([0.31 * i + 0.05, 0.17 * i + 0.11, 0.23 * i + 0.02]), np.array([0, 0, 0]) ) - # Child rotates and translates rt_child[i] = RotoTransMatrix.from_euler_angles_and_translation( - "xyz", np.array([i * 0.15, 0, 0]), np.array([0.1, 0.2, 0.3]) + "xyz", np.array([0.19 * i + 0.13, 0.29 * i + 0.07, 0.11 * i + 0.17]), np.array([0.1, 0.2, 0.3]) ) # Test get_svd @@ -1061,10 +1059,23 @@ def test_get_svd(): # U is too sensitive to be tested :( npt.assert_almost_equal( S, - np.array([4.47213595e00, 4.46062656e00, 4.46062656e00, 3.20641084e-01, 3.20641084e-01, 5.40977915e-16]), + np.array([4.42686475, 4.15458227, 4.11020407, 1.76244788, 1.65512723, 0.6347192]), decimal=3, ) - npt.assert_almost_equal(V[0, :], np.array([-0.70710678, 0.0, 0.0, -0.0, 0.0, 0.70710678]), decimal=3) + + # Fix the sign of each column of V, otherwise this would flip depending on the LAPACK backend even though the + # underlying subspace is identical. + V_sign = V.copy() + for i_col in range(V_sign.shape[1]): + i_max = np.argmax(np.abs(V_sign[:, i_col])) + if V_sign[i_max, i_col] < 0: + V_sign[:, i_col] *= -1 + npt.assert_almost_equal( + V_sign[0, :], + np.array([0.44883262, 0.32753639, -0.43734333, 0.43734333, -0.32753639, -0.44883262]), + decimal=3, + ) + npt.assert_almost_equal(b[:6], np.array([-0.1, -0.2, -0.3, -0.1, -0.2, -0.3]), decimal=3)