diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..064af4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,176 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build (${{ matrix.os }}, Python ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.9", "3.13"] + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Sync artifacts + run: python tools/sync_all.py + + - name: Build wheel + run: | + pip install build + python -m build --wheel -o dist + + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.os }}-py${{ matrix.python }} + path: dist/*.whl + + test: + name: Test wheel (${{ matrix.os }}, Python ${{ matrix.python }}) + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.9", "3.13"] + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: tests + sparse-checkout-cone-mode: false + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - uses: actions/download-artifact@v4 + with: + name: wheel-${{ matrix.os }}-py${{ matrix.python }} + path: dist + + - name: Install wheel and test dependencies + run: pip install dist/*.whl pytest + shell: bash + + - name: Run smoke tests + run: pytest tests/test_smoke.py -v + + examples: + name: Build example plugin (${{ matrix.os }}, Python ${{ matrix.python }}) + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.13"] + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + examples + sparse-checkout-cone-mode: false + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - uses: actions/download-artifact@v4 + with: + name: wheel-${{ matrix.os }}-py${{ matrix.python }} + path: dist + + - name: Install bsk-sdk wheel and dependencies + # swig is a transitive dependency of bsk-sdk; listed explicitly here so + # the version is visible. bsk_add_swig_module.cmake auto-configures + # SWIG_EXECUTABLE / SWIG_DIR / SWIG_LIB from the pip-installed package. + run: pip install dist/*.whl numpy bsk + shell: bash + + - name: Build example plugin wheel + run: | + pip install build scikit-build-core + python -m build --wheel --no-isolation -o plugin-dist examples/custom-atm-plugin + shell: bash + + - uses: actions/upload-artifact@v4 + with: + name: plugin-wheel-${{ matrix.os }}-py${{ matrix.python }} + path: plugin-dist/*.whl + retention-days: 7 + + - name: Install plugin wheel and test dependencies + run: pip install plugin-dist/*.whl pytest + shell: bash + + - name: Run example plugin tests + run: pytest examples/custom-atm-plugin/test_atm_plugin.py -v + + # Verify that the SWIG 4.x runtime type-table ABI is stable across minor + # versions within the same SWIG_RUNTIME_VERSION epoch: bsk is built with + # 4.3.1 (SWIG_RUNTIME_VERSION "4"); plugins may use any 4.0–4.3.x release. + # SWIG 4.4.0 bumped SWIG_RUNTIME_VERSION to "5", so >=4.4 is incompatible + # and excluded here (bsk-sdk pins swig<4.4 to prevent accidental use). + swig-compat: + name: SWIG compat (plugin=${{ matrix.plugin_swig }}, bsk=4.3.1) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + plugin_swig: ["4.0.0", "4.2.1", "4.3.1"] + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: examples + sparse-checkout-cone-mode: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - uses: actions/download-artifact@v4 + with: + name: wheel-ubuntu-latest-py3.13 + path: dist + + - name: Install bsk-sdk wheel and dependencies + # Install bsk (built with SWIG 4.3.1) then override the swig version + # bsk-sdk pulled in so the plugin is compiled with a different release. + run: | + pip install dist/*.whl numpy bsk + pip install "swig==${{ matrix.plugin_swig }}" --force-reinstall + shell: bash + + - name: Build example plugin wheel + run: | + pip install build scikit-build-core + python -m build --wheel --no-isolation -o plugin-dist examples/custom-atm-plugin + shell: bash + + - name: Install plugin wheel and run tests + run: | + pip install plugin-dist/*.whl pytest + pytest examples/custom-atm-plugin/test_atm_plugin.py -v + shell: bash diff --git a/.github/workflows/publish-wheels.yml b/.github/workflows/publish-wheels.yml new file mode 100644 index 0000000..78850bd --- /dev/null +++ b/.github/workflows/publish-wheels.yml @@ -0,0 +1,104 @@ +name: Publish Wheels + +on: + push: + tags: + - "v[0-9]*" + - "test*" + +jobs: + build-wheels: + name: Build wheels (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest # manylinux x86_64 + - ubuntu-22.04-arm # manylinux aarch64 + - macos-latest # macOS arm64 + - windows-latest # Windows x86_64 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Sync Basilisk artifacts (host) + run: python tools/sync_all.py + + - name: Build wheels + uses: pypa/cibuildwheel@v3.1.4 + env: + # Sync artifacts are already present from the host step above; + # the build backend will find them and skip auto-sync. + BSK_SDK_AUTO_SYNC: "0" + + - uses: actions/upload-artifact@v4 + with: + name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} + path: ./wheelhouse/*.whl + + make-sdist: + name: Build sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Sync Basilisk artifacts + run: python tools/sync_all.py + + - name: Build sdist + run: pipx run build --sdist + + - uses: actions/upload-artifact@v4 + with: + name: cibw-sdist + path: dist/*.tar.gz + + publish: + name: Publish to PyPI + needs: [build-wheels, make-sdist] + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Download wheels + uses: actions/download-artifact@v4 + with: + pattern: cibw-wheels-* + merge-multiple: true + path: dist + + - name: Download sdist + uses: actions/download-artifact@v4 + with: + name: cibw-sdist + path: dist + + - name: Publish to TestPyPI (test tags) + if: startsWith(github.ref, 'refs/tags/test') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist + skip-existing: true + verbose: true + + - name: Publish to PyPI (release tags) + if: startsWith(github.ref, 'refs/tags/v') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdcd331 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ + +src/bsk_sdk/include/Basilisk/* +src/bsk_sdk/arch_min/* +src/bsk_sdk/include_compat/* +src/bsk_sdk/runtime_min/* +src/bsk_sdk/swig/* + +# Standard Python ignores +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Sphinx documentation +docs/_build/ + +# UV +uv.lock + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a81fe4b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "external/basilisk"] + path = external/basilisk + url = https://github.com/AVSLab/basilisk.git + branch = v2.9.1 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..571c6a3 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,197 @@ +cmake_minimum_required(VERSION 3.18) + +# We compile both C and C++ sources (linearAlgebra.c etc.) +project(bsk_sdk LANGUAGES C CXX) + +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) + +# Python wheel layout +set(BSK_SDK_INCLUDE_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/bsk_sdk/include") +set(BSK_ARCH_MIN_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/bsk_sdk/arch_min") +set(BSK_RUNTIME_MIN_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/bsk_sdk/runtime_min") +set(BSK_SDK_COMPAT_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/bsk_sdk/include_compat") +set(BSK_SDK_SWIG_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/bsk_sdk/swig") +set(BSK_SDK_TOOLS_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/tools") + +# Prefer a system/vcpkg/conda Eigen3; fall back to downloading it automatically. +find_package(Eigen3 CONFIG QUIET) +if(NOT Eigen3_FOUND) + include(FetchContent) + FetchContent_Declare( + Eigen3 + URL https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.gz + URL_HASH SHA256=8586084f71f9bde545ee7fa6d00288b264a2b7ac3607b974e54d13e7162c1c72 + DOWNLOAD_EXTRACT_TIMESTAMP ON + ) + # Eigen3 is header-only — skip tests and install rules. + set(EIGEN_BUILD_DOC OFF CACHE BOOL "" FORCE) + set(BUILD_TESTING OFF CACHE BOOL "" FORCE) + set(EIGEN_BUILD_PKGCONFIG OFF CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(Eigen3) + message(STATUS "bsk-sdk: Eigen3 not found locally — downloaded via FetchContent") +endif() + +# Bundle Eigen3 headers into the wheel so downstream consumers are self-contained. +# Determine the directory that contains the Eigen/ subfolder. +if(DEFINED eigen3_SOURCE_DIR) + set(_bsk_eigen3_src "${eigen3_SOURCE_DIR}") +else() + get_target_property(_bsk_eigen3_incdirs Eigen3::Eigen INTERFACE_INCLUDE_DIRECTORIES) + list(GET _bsk_eigen3_incdirs 0 _bsk_eigen3_src) +endif() + +install( + DIRECTORY "${_bsk_eigen3_src}/Eigen" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/eigen3" +) +install( + DIRECTORY "${_bsk_eigen3_src}/unsupported" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/eigen3" + OPTIONAL +) + +# Header-only SDK target +add_library(bsk_sdk_headers INTERFACE) +add_library(bsk::sdk_headers ALIAS bsk_sdk_headers) + +target_compile_features(bsk_sdk_headers INTERFACE cxx_std_17) + +target_include_directories(bsk_sdk_headers INTERFACE + $ + $ + $ + $ + $ +) + +# Minimal runtime subset which is a build ALL curated .c/.cpp +file(GLOB BSK_RUNTIME_MIN_SOURCES + CONFIGURE_DEPENDS + "${BSK_RUNTIME_MIN_SRC_DIR}/*.c" + "${BSK_RUNTIME_MIN_SRC_DIR}/*.cpp" +) + +add_library(bsk_runtime_min STATIC ${BSK_RUNTIME_MIN_SOURCES}) +add_library(bsk::runtime_min ALIAS bsk_runtime_min) + +target_compile_features(bsk_runtime_min PUBLIC cxx_std_17) +target_link_libraries(bsk_runtime_min PUBLIC bsk::sdk_headers Eigen3::Eigen) + +set_target_properties(bsk_runtime_min PROPERTIES POSITION_INDEPENDENT_CODE ON) + +target_include_directories(bsk_runtime_min PUBLIC + $ + $ + $ + $ +) + +# Minimal Basilisk "arch" subset +file(GLOB BSK_ARCH_MIN_SOURCES + CONFIGURE_DEPENDS + "${BSK_ARCH_MIN_SRC_DIR}/*.c" + "${BSK_ARCH_MIN_SRC_DIR}/*.cpp" +) + +add_library(bsk_arch_min STATIC ${BSK_ARCH_MIN_SOURCES}) +add_library(bsk::arch_min ALIAS bsk_arch_min) + +target_compile_features(bsk_arch_min PUBLIC cxx_std_17) +target_link_libraries(bsk_arch_min PUBLIC bsk::sdk_headers) + +set_target_properties(bsk_arch_min PROPERTIES POSITION_INDEPENDENT_CODE ON) + +target_include_directories(bsk_arch_min PUBLIC + $ + $ +) + +target_include_directories(bsk_arch_min PRIVATE + ${BSK_SDK_INCLUDE_SRC_DIR}/Basilisk/architecture/utilities + ${BSK_SDK_INCLUDE_SRC_DIR}/Basilisk/architecture/utilities/moduleIdGenerator +) + + +add_library(bsk_plugin INTERFACE) +add_library(bsk::bsk_plugin ALIAS bsk_plugin) + +target_link_libraries(bsk_plugin INTERFACE + bsk::sdk_headers + bsk::arch_min + bsk::runtime_min +) + +install( + DIRECTORY "${BSK_SDK_INCLUDE_SRC_DIR}/" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" +) + +install( + DIRECTORY "${BSK_SDK_COMPAT_INCLUDE_DIR}/" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/compat" +) + +install( + DIRECTORY "${BSK_RUNTIME_MIN_SRC_DIR}/" + DESTINATION "runtime_min" + FILES_MATCHING + PATTERN "*.c" + PATTERN "*.cpp" + PATTERN "*.h" +) + +install( + DIRECTORY "${BSK_SDK_SWIG_SRC_DIR}/" + DESTINATION "swig" + COMPONENT python +) + +install( + DIRECTORY "${BSK_SDK_TOOLS_SRC_DIR}/" + DESTINATION "tools" + PATTERN "__pycache__" EXCLUDE +) + +install( + TARGETS bsk_arch_min bsk_runtime_min bsk_sdk_headers bsk_plugin + EXPORT bsk-sdkTargets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" +) + +install( + EXPORT bsk-sdkTargets + NAMESPACE bsk:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/bsk-sdk" +) + +install( + FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/bsk_sdk/__init__.py" + DESTINATION "." +) + +configure_package_config_file( + cmake/bsk-sdkConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/bsk-sdkConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/bsk-sdk" +) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/bsk-sdkConfigVersion.cmake" + VERSION ${SKBUILD_PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/bsk-sdkConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/bsk-sdkConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/bsk-sdk" +) + +install( + FILES + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/bsk_add_swig_module.cmake" + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/bsk_generate_messages.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/bsk-sdk" +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bce2843 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Basilisk SDK + +This package publishes the Basilisk plugin SDK so that external projects can +build Basilisk-compatible SWIG based plugins without vendoring the full simulation +codebase. It ships curated Basilisk headers, SWIG interface files and typemaps, +message auto-generation tools, and CMake helper functions for building SWIG +modules out-of-tree. + +## Syncing from Basilisk (versioned) + +Recommended approach: add Basilisk as a Git submodule so the SDK can sync from a +pinned Basilisk commit. + +```bash +git submodule add https://github.com/AVSLab/basilisk.git external/basilisk +git submodule update --init --recursive +``` + +Commit policy: + +- Commit `.gitmodules` and the `external/basilisk` gitlink (submodule pointer). +- Do **not** vendor/copy Basilisk repo contents directly into this SDK repo. + +Then run: + +```bash +python3 tools/sync_all.py --sync-submodules +pip install -e . +``` + +By default, builds are side-effect free and require sync artifacts to already be +present. + +If you want convenience auto-sync during build, opt in: + +```bash +BSK_SDK_AUTO_SYNC=1 pip install -e . +``` + +Build/sync behavior can be controlled with environment variables: + +- `BSK_SDK_AUTO_SYNC=1` enables auto-sync during build. +- `BSK_SDK_SYNC_SUBMODULES=0` skips submodule update during auto-sync. +- `BSK_BASILISK_ROOT=/path/to/basilisk` overrides source location. + +Path resolution order used by sync scripts: + +1. `--basilisk-root ` +2. `BSK_BASILISK_ROOT=` +3. `external/basilisk` (submodule default) +4. `../basilisk` (legacy sibling fallback) + +Examples: + +```bash +python3 tools/sync_all.py --basilisk-root ~/src/basilisk +BSK_BASILISK_ROOT=~/src/basilisk python3 tools/sync_all.py +``` + +To update to a newer Basilisk version: + +```bash +cd external/basilisk +git fetch +git checkout +cd ../.. +python3 tools/sync_all.py +``` diff --git a/build_backend.py b/build_backend.py new file mode 100644 index 0000000..a323257 --- /dev/null +++ b/build_backend.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +from scikit_build_core import build as _backend + + +def _truthy(value: str | None, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _required_sync_paths(repo_root: Path) -> list[Path]: + sdk_src = repo_root / "src" / "bsk_sdk" + return [ + sdk_src / "include" / "Basilisk", + sdk_src / "arch_min", + sdk_src / "runtime_min", + sdk_src / "swig", + ] + + +def _has_any_file(path: Path) -> bool: + if not path.exists() or not path.is_dir(): + return False + return any(child.is_file() for child in path.rglob("*")) + + +def _assert_synced_artifacts(repo_root: Path) -> None: + missing = [p for p in _required_sync_paths(repo_root) if not _has_any_file(p)] + if not missing: + return + + missing_text = "\n".join(f" - {p}" for p in missing) + raise RuntimeError( + "bsk-sdk vendored Basilisk artifacts are missing or empty:\n" + f"{missing_text}\n\n" + "Run this first:\n" + " python3 tools/sync_all.py --sync-submodules\n\n" + "Or opt into auto-sync during build:\n" + " BSK_SDK_AUTO_SYNC=1 pip install -e ." + ) + + +def _run_sync() -> None: + repo_root = Path(__file__).resolve().parent + sync_script = repo_root / "tools" / "sync_all.py" + if not sync_script.exists(): + return + + cmd = [sys.executable, str(sync_script)] + + if _truthy(os.environ.get("BSK_SDK_SYNC_SUBMODULES"), default=True): + cmd.append("--sync-submodules") + + basilisk_root = os.environ.get("BSK_BASILISK_ROOT") + if basilisk_root: + cmd.extend(["--basilisk-root", basilisk_root]) + + subprocess.run(cmd, cwd=repo_root, check=True) + + +def _prepare_artifacts() -> None: + repo_root = Path(__file__).resolve().parent + auto_sync = _truthy(os.environ.get("BSK_SDK_AUTO_SYNC"), default=False) + if auto_sync: + _run_sync() + + _assert_synced_artifacts(repo_root) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, str] | None = None, + metadata_directory: str | None = None, +) -> str: + _prepare_artifacts() + return _backend.build_wheel( + wheel_directory, config_settings, metadata_directory + ) + + +def build_editable( + wheel_directory: str, + config_settings: dict[str, str] | None = None, + metadata_directory: str | None = None, +) -> str: + _prepare_artifacts() + return _backend.build_editable( + wheel_directory, config_settings, metadata_directory + ) + + +def build_sdist( + sdist_directory: str, + config_settings: dict[str, str] | None = None, +) -> str: + _prepare_artifacts() + return _backend.build_sdist(sdist_directory, config_settings) + + +def get_requires_for_build_wheel( + config_settings: dict[str, str] | None = None, +) -> list[str]: + return _backend.get_requires_for_build_wheel(config_settings) + + +def get_requires_for_build_editable( + config_settings: dict[str, str] | None = None, +) -> list[str]: + return _backend.get_requires_for_build_editable(config_settings) + + +def get_requires_for_build_sdist( + config_settings: dict[str, str] | None = None, +) -> list[str]: + return _backend.get_requires_for_build_sdist(config_settings) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, str] | None = None, +) -> str: + return _backend.prepare_metadata_for_build_wheel( + metadata_directory, config_settings + ) + + +def prepare_metadata_for_build_editable( + metadata_directory: str, + config_settings: dict[str, str] | None = None, +) -> str: + return _backend.prepare_metadata_for_build_editable( + metadata_directory, config_settings + ) diff --git a/cmake/bsk-sdkConfig.cmake.in b/cmake/bsk-sdkConfig.cmake.in new file mode 100644 index 0000000..49f8666 --- /dev/null +++ b/cmake/bsk-sdkConfig.cmake.in @@ -0,0 +1,39 @@ +@PACKAGE_INIT@ + +get_filename_component(_bsk_sdk_config_dir "${CMAKE_CURRENT_LIST_FILE}" PATH) +get_filename_component(_bsk_sdk_cmake_dir "${_bsk_sdk_config_dir}" PATH) # .../cmake +get_filename_component(_bsk_sdk_lib_dir "${_bsk_sdk_cmake_dir}" PATH) # .../lib +get_filename_component(_bsk_sdk_pkg_dir "${_bsk_sdk_lib_dir}" PATH) # + +set(BSK_SDK_PKG_DIR "${_bsk_sdk_pkg_dir}" CACHE PATH "bsk-sdk package root") +set(BSK_SDK_INCLUDE_DIR "${BSK_SDK_PKG_DIR}/include" CACHE PATH "bsk-sdk include root") +set(BSK_SDK_COMPAT_INCLUDE_DIR "${BSK_SDK_INCLUDE_DIR}/compat" CACHE PATH "bsk-sdk compat include dir") +set(BSK_SDK_RUNTIME_MIN_DIR "${BSK_SDK_PKG_DIR}/runtime_min" CACHE PATH "bsk-sdk runtime_min dir (if shipped)") +set(BSK_SDK_SWIG_DIR "${BSK_SDK_PKG_DIR}/swig" CACHE PATH "bsk-sdk swig dir") +set(BSK_SDK_TOOLS_DIR "${BSK_SDK_PKG_DIR}/tools" CACHE PATH "bsk-sdk tools dir") + +# Provide Eigen3::Eigen from bundled headers so plugins are self-contained. +if(NOT TARGET Eigen3::Eigen) + add_library(Eigen3::Eigen IMPORTED INTERFACE GLOBAL) + set_target_properties(Eigen3::Eigen PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${BSK_SDK_INCLUDE_DIR}/eigen3" + ) +endif() + +include("${_bsk_sdk_config_dir}/bsk-sdkTargets.cmake") + +function(_bsk_sdk_make_alias_if_needed alias_name real_name) + if(TARGET "${real_name}" AND NOT TARGET "${alias_name}") + add_library("${alias_name}" ALIAS "${real_name}") + endif() +endfunction() + +_bsk_sdk_make_alias_if_needed(bsk::sdk_headers bsk::bsk_sdk_headers) +_bsk_sdk_make_alias_if_needed(bsk::arch_min bsk::bsk_arch_min) +_bsk_sdk_make_alias_if_needed(bsk::runtime_min bsk::bsk_runtime_min) +_bsk_sdk_make_alias_if_needed(bsk::plugin bsk::bsk_plugin) + +include("${_bsk_sdk_config_dir}/bsk_add_swig_module.cmake") +include("${_bsk_sdk_config_dir}/bsk_generate_messages.cmake") + +check_required_components(bsk-sdk) diff --git a/cmake/bsk_add_swig_module.cmake b/cmake/bsk_add_swig_module.cmake new file mode 100644 index 0000000..7d17c82 --- /dev/null +++ b/cmake/bsk_add_swig_module.cmake @@ -0,0 +1,251 @@ +include_guard(GLOBAL) +include(CMakeParseArguments) + +function(_bsk_collect_swig_flags out_var) + set(_flags + "-I${BSK_SDK_SWIG_DIR}" + "-I${BSK_SDK_SWIG_DIR}/architecture" + "-I${BSK_SDK_SWIG_DIR}/architecture/_GeneralModuleFiles" + + "-I${BSK_SDK_INCLUDE_DIR}" + "-I${BSK_SDK_INCLUDE_DIR}/Basilisk" + "-I${BSK_SDK_INCLUDE_DIR}/Basilisk/architecture" + "-I${BSK_SDK_INCLUDE_DIR}/Basilisk/architecture/_GeneralModuleFiles" + + "-I${Python3_INCLUDE_DIRS}" + ) + + if(DEFINED BSK_SDK_COMPAT_INCLUDE_DIR AND EXISTS "${BSK_SDK_COMPAT_INCLUDE_DIR}") + list(APPEND _flags "-I${BSK_SDK_COMPAT_INCLUDE_DIR}") + endif() + + if(Python3_NumPy_INCLUDE_DIRS) + list(APPEND _flags "-I${Python3_NumPy_INCLUDE_DIRS}") + endif() + + foreach(_dir IN LISTS BSK_SWIG_INCLUDE_DIRS) + list(APPEND _flags "-I${_dir}") + endforeach() + + foreach(_dir IN LISTS BSK_INCLUDE_DIRS) + list(APPEND _flags "-I${_dir}") + endforeach() + + set(${out_var} "${_flags}" PARENT_SCOPE) +endfunction() + +function(_bsk_resolve_basilisk_libs out_var) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + + execute_process( + COMMAND "${Python3_EXECUTABLE}" -c + "import Basilisk, pathlib; print(pathlib.Path(Basilisk.__file__).resolve().parent, end='')" + OUTPUT_VARIABLE _bsk_root + RESULT_VARIABLE _bsk_res + ) + if(NOT _bsk_res EQUAL 0 OR NOT EXISTS "${_bsk_root}") + message(FATAL_ERROR "Failed to locate installed Basilisk package (needed to link runtime libs).") + endif() + + set(_lib_dir "${_bsk_root}") + set(_names architectureLib ArchitectureUtilities cMsgCInterface) + set(_libs "") + foreach(_name IN LISTS _names) + find_library(_lib NAMES ${_name} PATHS "${_lib_dir}" NO_DEFAULT_PATH) + if(NOT _lib) + message(FATAL_ERROR "Basilisk library '${_name}' not found under ${_lib_dir}") + endif() + list(APPEND _libs "${_lib}") + endforeach() + + set(${out_var} "${_libs}" PARENT_SCOPE) +endfunction() + +# Resolve the pip-installed swig from the active Python interpreter. +# Creates a thin wrapper script that exports SWIG_LIB before exec'ing the +# real binary, then sets SWIG_EXECUTABLE to the wrapper and SWIG_DIR for +# FindSWIG. This ensures every swig invocation by ninja gets the right lib +# path without any manual configuration from the plugin author. +function(_bsk_setup_pip_swig python_exe) + if(NOT python_exe) + return() + endif() + + execute_process( + COMMAND "${python_exe}" -c + "import swig, os, pathlib; \ +exe = 'swig.exe' if os.name == 'nt' else 'swig'; \ +print(pathlib.Path(swig.BIN_DIR, exe).as_posix(), end='')" + OUTPUT_VARIABLE _pip_swig_exe + RESULT_VARIABLE _pip_swig_rc + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + execute_process( + COMMAND "${python_exe}" -c + "import swig, pathlib; \ +print(pathlib.Path(swig.SWIG_SHARE_DIR, swig.__version__).as_posix(), end='')" + OUTPUT_VARIABLE _pip_swig_lib + RESULT_VARIABLE _pip_swig_lib_rc + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if(_pip_swig_rc EQUAL 0 AND EXISTS "${_pip_swig_exe}" + AND _pip_swig_lib_rc EQUAL 0 AND EXISTS "${_pip_swig_lib}") + # SWIG_DIR is needed by FindSWIG during cmake configure to locate swig.swg. + set(SWIG_DIR "${_pip_swig_lib}" CACHE PATH "SWIG lib from pip" FORCE) + + # set(ENV{SWIG_LIB} ...) only affects the cmake configure process — ninja + # subprocesses that invoke swig during the build don't inherit it. Wrap + # the real swig binary in a thin script that exports SWIG_LIB first, then + # point SWIG_EXECUTABLE at the wrapper so every swig invocation gets it. + if(WIN32) + set(_wrapper "${CMAKE_BINARY_DIR}/bsk_swig_wrapper.bat") + file(WRITE "${_wrapper}" + "@echo off\r\n" + "set \"SWIG_LIB=${_pip_swig_lib}\"\r\n" + "\"${_pip_swig_exe}\" %*\r\n" + ) + else() + set(_wrapper "${CMAKE_BINARY_DIR}/bsk_swig_wrapper.sh") + file(WRITE "${_wrapper}" + "#!/bin/sh\n" + "export SWIG_LIB=\"${_pip_swig_lib}\"\n" + "exec \"${_pip_swig_exe}\" \"$@\"\n" + ) + execute_process(COMMAND chmod +x "${_wrapper}") + endif() + + set(SWIG_EXECUTABLE "${_wrapper}" CACHE FILEPATH "SWIG wrapper from pip" FORCE) + message(STATUS "bsk-sdk: using pip SWIG ${_pip_swig_exe} (lib: ${_pip_swig_lib})") + + # Runtime-version guard: SWIG_RUNTIME_VERSION in swigrun.swg determines + # the sys.modules capsule name (swig_runtime_dataX). Modules compiled with + # different values cannot share the type table, so cross-module type casts + # (e.g. passing a plugin object to Basilisk's AddModelToTask) silently fail. + # Detect the mismatch here and abort with a clear message. + file(STRINGS "${_pip_swig_lib}/swigrun.swg" _swigrun_lines + REGEX "SWIG_RUNTIME_VERSION") + set(_plugin_rt "") + foreach(_line IN LISTS _swigrun_lines) + if(_line MATCHES "#define SWIG_RUNTIME_VERSION \"([0-9]+)\"") + set(_plugin_rt "${CMAKE_MATCH_1}") + endif() + endforeach() + + execute_process( + COMMAND "${python_exe}" -c + "import sys\n\ +try:\n\ + import Basilisk.architecture.cSysModel\n\ + keys = [k for k in sys.modules if k.startswith('swig_runtime_data')]\n\ + print(keys[0].replace('swig_runtime_data', '') if keys else '', end='')\n\ +except ImportError:\n\ + print('', end='')" + OUTPUT_VARIABLE _bsk_rt + RESULT_VARIABLE _bsk_rt_rc + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if(_plugin_rt AND _bsk_rt AND NOT _plugin_rt STREQUAL _bsk_rt) + message(FATAL_ERROR + "SWIG runtime version mismatch!\n" + " bsk was compiled with SWIG_RUNTIME_VERSION \"${_bsk_rt}\" " + "(capsule: swig_runtime_data${_bsk_rt})\n" + " pip swig ${_pip_swig_exe} uses SWIG_RUNTIME_VERSION \"${_plugin_rt}\" " + "(capsule: swig_runtime_data${_plugin_rt})\n\n" + "Plugins compiled with this SWIG version cannot exchange objects with " + "Basilisk across module boundaries.\n" + "Install a SWIG version that matches bsk's runtime epoch.\n" + " bsk-sdk declares: swig>=4.0,<4.4 (runtime version 4, matching bsk 4.3.1)\n" + " Reinstall with: pip install \"swig>=4.0,<4.4\"" + ) + endif() + endif() +endfunction() + +function(bsk_add_swig_module) + set(oneValueArgs TARGET INTERFACE OUTPUT_DIR) + set(multiValueArgs SOURCES INCLUDE_DIRS SWIG_INCLUDE_DIRS LINK_LIBS DEPENDS) + cmake_parse_arguments(BSK "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT BSK_TARGET OR NOT BSK_INTERFACE) + message(FATAL_ERROR "bsk_add_swig_module requires TARGET and INTERFACE (.i file)") + endif() + + if(NOT BSK_OUTPUT_DIR) + set(BSK_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + endif() + + find_package(Python3 QUIET COMPONENTS Interpreter) + _bsk_setup_pip_swig("${Python3_EXECUTABLE}") + + find_package(SWIG REQUIRED COMPONENTS python) + include(${SWIG_USE_FILE}) + + find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module NumPy) + find_package(Eigen3 CONFIG REQUIRED) + + if(NOT BSK_LINK_LIBS) + if(TARGET bsk::arch_min) + set(BSK_LINK_LIBS "bsk::arch_min") + else() + _bsk_resolve_basilisk_libs(_bsk_libs) + set(BSK_LINK_LIBS "${_bsk_libs}") + endif() + endif() + + _bsk_collect_swig_flags(_swig_flags) + + set_property(SOURCE "${BSK_INTERFACE}" PROPERTY CPLUSPLUS ON) + set_property(SOURCE "${BSK_INTERFACE}" PROPERTY USE_TARGET_INCLUDE_DIRECTORIES TRUE) + set_property(SOURCE "${BSK_INTERFACE}" PROPERTY SWIG_FLAGS ${_swig_flags}) + + set(_wrap_dir "${CMAKE_CURRENT_BINARY_DIR}/swig/${BSK_TARGET}") + file(MAKE_DIRECTORY "${_wrap_dir}") + + swig_add_library( + ${BSK_TARGET} + LANGUAGE python + TYPE MODULE + SOURCES "${BSK_INTERFACE}" ${BSK_SOURCES} + OUTPUT_DIR "${BSK_OUTPUT_DIR}" + OUTFILE_DIR "${_wrap_dir}" + ) + + target_include_directories(${BSK_TARGET} PRIVATE + ${BSK_INCLUDE_DIRS} + "${BSK_SDK_INCLUDE_DIR}" + "${BSK_SDK_INCLUDE_DIR}/Basilisk" + "${BSK_SDK_INCLUDE_DIR}/Basilisk/architecture" + "${BSK_SDK_INCLUDE_DIR}/Basilisk/architecture/_GeneralModuleFiles" + $<$:${BSK_SDK_COMPAT_INCLUDE_DIR}> + ${Python3_INCLUDE_DIRS} + ${Python3_NumPy_INCLUDE_DIRS} + ) + + target_link_libraries(${BSK_TARGET} PRIVATE + Python3::Module + Eigen3::Eigen + ${BSK_LINK_LIBS} + ) + + if(BSK_DEPENDS) + add_dependencies(${BSK_TARGET} ${BSK_DEPENDS}) + endif() + + set_target_properties(${BSK_TARGET} PROPERTIES + POSITION_INDEPENDENT_CODE ON + LIBRARY_OUTPUT_DIRECTORY "${BSK_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY "${BSK_OUTPUT_DIR}" + ) + # On MSVC multi-config generators the per-config variants take precedence and + # default to appending Release/Debug/... subdirectories. Override them so the + # output always lands in BSK_OUTPUT_DIR regardless of generator. + foreach(_cfg RELEASE DEBUG RELWITHDEBINFO MINSIZEREL) + set_target_properties(${BSK_TARGET} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY_${_cfg} "${BSK_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY_${_cfg} "${BSK_OUTPUT_DIR}" + ) + endforeach() +endfunction() diff --git a/cmake/bsk_generate_messages.cmake b/cmake/bsk_generate_messages.cmake new file mode 100644 index 0000000..3d7ed8d --- /dev/null +++ b/cmake/bsk_generate_messages.cmake @@ -0,0 +1,191 @@ +include_guard(GLOBAL) +include(CMakeParseArguments) + +if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/bsk_add_swig_module.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/bsk_add_swig_module.cmake") +endif() + +function(_bsk_resolve_msg_autosource_dir out_var) + if(DEFINED BSK_SDK_MSG_AUTOSOURCE_DIR) + if(EXISTS "${BSK_SDK_MSG_AUTOSOURCE_DIR}/generateSWIGModules.py") + set(${out_var} "${BSK_SDK_MSG_AUTOSOURCE_DIR}" PARENT_SCOPE) + return() + endif() + endif() + + if(DEFINED bsk-sdk_DIR AND EXISTS "${bsk-sdk_DIR}") + set(_cmake_dir "${bsk-sdk_DIR}") + else() + set(_cmake_dir "${CMAKE_CURRENT_FUNCTION_LIST_DIR}") + endif() + + get_filename_component(_pkg_root "${_cmake_dir}/../../.." REALPATH) + + set(_cand_installed "${_pkg_root}/tools/msgAutoSource") + set(_cand_source "${_cmake_dir}/../tools/msgAutoSource") + set(_candidates + "${_cand_installed}" + "${_cand_source}" + ) + + foreach(_cand IN LISTS _candidates) + if(EXISTS "${_cand}/generateSWIGModules.py") + set(${out_var} "${_cand}" PARENT_SCOPE) + return() + endif() + endforeach() + + string(JOIN "\n " _cand_list ${_candidates}) + message(FATAL_ERROR + "bsk-sdk message generator not found.\n\n" + "Looked for generateSWIGModules.py under:\n" + " ${_cand_list}\n\n" + "Resolver context:\n" + " bsk-sdk_DIR = ${bsk-sdk_DIR}\n" + " CMAKE_CURRENT_FUNCTION_LIST_DIR = ${CMAKE_CURRENT_FUNCTION_LIST_DIR}\n" + " (note: caller CMAKE_CURRENT_LIST_DIR is irrelevant here)\n\n" + "If using an installed wheel, ensure it installs:\n" + " bsk_sdk/tools/msgAutoSource/generateSWIGModules.py\n" + "If building from source, ensure this exists:\n" + " sdk/tools/msgAutoSource/generateSWIGModules.py\n" + "Or set BSK_SDK_MSG_AUTOSOURCE_DIR explicitly." + ) +endfunction() + +# Main +function(bsk_generate_messages) + set(options GENERATE_C_INTERFACE) + set(oneValueArgs OUTPUT_DIR OUT_VAR) + set(multiValueArgs MSG_HEADERS INCLUDE_DIRS SWIG_INCLUDE_DIRS TARGET_LINK_LIBS) + cmake_parse_arguments(BSK "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT BSK_MSG_HEADERS) + message(FATAL_ERROR "bsk_generate_messages requires MSG_HEADERS") + endif() + + if(NOT BSK_OUTPUT_DIR) + set(BSK_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + endif() + + find_package(Python3 QUIET COMPONENTS Interpreter) + _bsk_setup_pip_swig("${Python3_EXECUTABLE}") + + find_package(SWIG REQUIRED COMPONENTS python) + include(${SWIG_USE_FILE}) + find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module NumPy) + find_package(Eigen3 CONFIG REQUIRED) + + if(NOT BSK_TARGET_LINK_LIBS) + if(TARGET bsk::arch_min) + set(BSK_TARGET_LINK_LIBS "bsk::arch_min") + else() + _bsk_resolve_basilisk_libs(_bsk_libs) + set(BSK_TARGET_LINK_LIBS "${_bsk_libs}") + endif() + endif() + + + _bsk_collect_swig_flags(_swig_flags) + + _bsk_resolve_msg_autosource_dir(_msg_autosrc) + set(_gen_swig "${_msg_autosrc}/generateSWIGModules.py") + + set(_auto_root "${CMAKE_CURRENT_BINARY_DIR}/autoSource") + set(_xml_dir "${_auto_root}/xmlWrap") + file(MAKE_DIRECTORY "${_xml_dir}") + + set(_generated_targets "") + set(_gen_c "False") + if(BSK_GENERATE_C_INTERFACE) + set(_gen_c "True") + endif() + + foreach(_hdr IN LISTS BSK_MSG_HEADERS) + get_filename_component(_hdr_abs "${_hdr}" ABSOLUTE) + get_filename_component(_payload_name "${_hdr_abs}" NAME_WE) + get_filename_component(_hdr_dir "${_hdr_abs}" DIRECTORY) + + set(_xml_out "${_xml_dir}/${_payload_name}.xml") + add_custom_command( + OUTPUT "${_xml_out}" + COMMAND ${SWIG_EXECUTABLE} -c++ -xml -module dummy -o "${_xml_out}" "${_hdr_abs}" + DEPENDS "${_hdr_abs}" + COMMENT "Generating SWIG XML for ${_payload_name}" + VERBATIM + ) + + set(_i_out "${_auto_root}/${_payload_name}.i") + add_custom_command( + OUTPUT "${_i_out}" + COMMAND ${Python3_EXECUTABLE} + "${_gen_swig}" + "${_i_out}" "${_hdr_abs}" "${_payload_name}" "${_hdr_dir}" + "${_gen_c}" + "${_xml_out}" 0 + DEPENDS "${_hdr_abs}" "${_xml_out}" + WORKING_DIRECTORY "${_msg_autosrc}" + COMMENT "Generating SWIG interface for ${_payload_name}" + VERBATIM + ) + + set_property(SOURCE "${_i_out}" PROPERTY CPLUSPLUS ON) + set_property(SOURCE "${_i_out}" PROPERTY USE_TARGET_INCLUDE_DIRECTORIES TRUE) + set_property(SOURCE "${_i_out}" PROPERTY SWIG_FLAGS ${_swig_flags}) # LIST, not string + + swig_add_library( + ${_payload_name} + LANGUAGE python + TYPE MODULE + SOURCES "${_i_out}" + OUTPUT_DIR "${BSK_OUTPUT_DIR}" + OUTFILE_DIR "${_auto_root}" + ) + + target_include_directories(${_payload_name} PRIVATE + ${BSK_INCLUDE_DIRS} + ${_hdr_dir} + "${BSK_SDK_INCLUDE_DIR}" + "${BSK_SDK_INCLUDE_DIR}/Basilisk" + "${BSK_SDK_INCLUDE_DIR}/Basilisk/architecture" + "${BSK_SDK_INCLUDE_DIR}/Basilisk/architecture/_GeneralModuleFiles" + $<$:${BSK_SDK_COMPAT_INCLUDE_DIR}> + ${Python3_INCLUDE_DIRS} + ${Python3_NumPy_INCLUDE_DIRS} + ) + + target_link_libraries(${_payload_name} PRIVATE + Python3::Module + Eigen3::Eigen + ${BSK_TARGET_LINK_LIBS} + ) + + set_target_properties(${_payload_name} PROPERTIES + POSITION_INDEPENDENT_CODE ON + LIBRARY_OUTPUT_DIRECTORY "${BSK_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY "${BSK_OUTPUT_DIR}" + ) + # On MSVC multi-config generators the per-config variants take precedence and + # default to appending Release/Debug/... subdirectories. Override them so the + # output always lands in BSK_OUTPUT_DIR regardless of generator. + foreach(_cfg RELEASE DEBUG RELWITHDEBINFO MINSIZEREL) + set_target_properties(${_payload_name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY_${_cfg} "${BSK_OUTPUT_DIR}" + RUNTIME_OUTPUT_DIRECTORY_${_cfg} "${BSK_OUTPUT_DIR}" + ) + endforeach() + + list(APPEND _generated_targets ${_payload_name}) + endforeach() + + file(MAKE_DIRECTORY "${BSK_OUTPUT_DIR}") + set(_init_file "${BSK_OUTPUT_DIR}/__init__.py") + file(WRITE "${_init_file}" "") + foreach(_hdr IN LISTS BSK_MSG_HEADERS) + get_filename_component(_payload_name "${_hdr}" NAME_WE) + file(APPEND "${_init_file}" "from .${_payload_name} import *\n") + endforeach() + + if(BSK_OUT_VAR) + set(${BSK_OUT_VAR} "${_generated_targets}" PARENT_SCOPE) + endif() +endfunction() diff --git a/examples/custom-atm-plugin/CMakeLists.txt b/examples/custom-atm-plugin/CMakeLists.txt new file mode 100644 index 0000000..f107308 --- /dev/null +++ b/examples/custom-atm-plugin/CMakeLists.txt @@ -0,0 +1,126 @@ +cmake_minimum_required(VERSION 3.18) +project(bsk_plugin_exponential_atmosphere LANGUAGES C CXX) + +find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module NumPy) + +# Force SWIG invocation through the active Python environment to avoid picking +# a system SWIG launcher bound to a different Python/runtime context. +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import swig; print(swig.__version__, end='')" + OUTPUT_VARIABLE py_swig_version + RESULT_VARIABLE py_swig_rc +) +if(py_swig_rc EQUAL 0) + set(_swig_wrapper "${CMAKE_CURRENT_BINARY_DIR}/swig_from_python") + file(WRITE "${_swig_wrapper}" "#!/bin/sh\nexec \"${Python3_EXECUTABLE}\" -m swig \"$@\"\n") + execute_process(COMMAND chmod +x "${_swig_wrapper}") + set(SWIG_EXECUTABLE "${_swig_wrapper}" CACHE FILEPATH "SWIG executable" FORCE) + message(STATUS "Using Python SWIG ${py_swig_version} via ${SWIG_EXECUTABLE}") +endif() + +# Locate bsk-sdk's CMake config dir from the active Python env +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import bsk_sdk; print(bsk_sdk.cmake_config_dir(), end='')" + OUTPUT_VARIABLE bsk_sdk_dir + RESULT_VARIABLE rc +) +if(NOT rc EQUAL 0 OR bsk_sdk_dir STREQUAL "") + message(FATAL_ERROR "bsk-sdk not found (is it installed in this Python environment?)") +endif() +file(TO_CMAKE_PATH "${bsk_sdk_dir}" bsk_sdk_dir) + +set(bsk-sdk_DIR "${bsk_sdk_dir}") +find_package(bsk-sdk CONFIG REQUIRED) + +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import bsk_sdk; print(bsk_sdk.package_root(), end='')" + OUTPUT_VARIABLE bsk_sdk_pkg_dir + RESULT_VARIABLE bsk_sdk_pkg_rc +) +if(NOT bsk_sdk_pkg_rc EQUAL 0 OR bsk_sdk_pkg_dir STREQUAL "") + message(FATAL_ERROR "Could not resolve bsk_sdk package_root()") +endif() +file(TO_CMAKE_PATH "${bsk_sdk_pkg_dir}" bsk_sdk_pkg_dir) + +# Resolve Basilisk runtime libraries from the active Python environment. +# The plugin must link against the same Basilisk libs that back +# Basilisk.utilities.SimulationBaseClass to keep SysModel C++ type identity +# consistent across module boundaries. +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import Basilisk, pathlib; print(pathlib.Path(Basilisk.__file__).resolve().parent, end='')" + OUTPUT_VARIABLE basilisk_pkg_dir + RESULT_VARIABLE basilisk_rc +) +if(NOT basilisk_rc EQUAL 0 OR basilisk_pkg_dir STREQUAL "") + message(FATAL_ERROR "Basilisk package not found in this Python environment") +endif() +file(TO_CMAKE_PATH "${basilisk_pkg_dir}" basilisk_pkg_dir) + +set(_basilisk_lib_names architectureLib ArchitectureUtilities cMsgCInterface) +set(BASILISK_RUNTIME_LIBS "") +foreach(_libname IN LISTS _basilisk_lib_names) + find_library(_libpath NAMES ${_libname} PATHS "${basilisk_pkg_dir}" NO_DEFAULT_PATH) + if(NOT _libpath) + message(FATAL_ERROR "Basilisk runtime library '${_libname}' not found under ${basilisk_pkg_dir}") + endif() + list(APPEND BASILISK_RUNTIME_LIBS "${_libpath}") +endforeach() + +# Where scikit-build-core wants wheel outputs +set(PLUGIN_PKG_DIR "${SKBUILD_PLATLIB_DIR}/custom_atm") + +# Plugin sources +file(GLOB PLUGIN_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c" + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cc" + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cxx" + "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp" +) + +# AtmosphereBase implementation is required at link/load time for the custom +# model subclass. Prefer the SDK-shipped runtime source to avoid depending on +# repository-local external/basilisk checkout structure in CI. +set(_atm_base_impl "${bsk_sdk_pkg_dir}/runtime_min/atmosphereBase.cpp") +if(NOT EXISTS "${_atm_base_impl}") + set(_atm_base_impl + "${CMAKE_CURRENT_SOURCE_DIR}/../../src/bsk_sdk/runtime_min/atmosphereBase.cpp" + ) +endif() +if(EXISTS "${_atm_base_impl}") + list(APPEND PLUGIN_SOURCES "${_atm_base_impl}") +else() + message(FATAL_ERROR "atmosphereBase.cpp not found in bsk_sdk runtime_min") +endif() + +set(_lin_alg_impl + "${CMAKE_CURRENT_SOURCE_DIR}/../../external/basilisk/src/architecture/utilities/linearAlgebra.c" +) +if(EXISTS "${_lin_alg_impl}") + set_source_files_properties("${_lin_alg_impl}" PROPERTIES LANGUAGE C) + list(APPEND PLUGIN_SOURCES "${_lin_alg_impl}") +endif() + +# Build the SWIG module +bsk_add_swig_module( + TARGET customExponentialAtmosphere + INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/swig/customExponentialAtmosphere.i" + SOURCES ${PLUGIN_SOURCES} + INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/src" + LINK_LIBS ${BASILISK_RUNTIME_LIBS} bsk::arch_min + OUTPUT_DIR "${PLUGIN_PKG_DIR}" +) + +target_include_directories(customExponentialAtmosphere PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}/messages" +) + +# Generate message bindings into custom_atm/messaging +bsk_generate_messages( + OUTPUT_DIR "${PLUGIN_PKG_DIR}/messaging" + MSG_HEADERS + "${CMAKE_CURRENT_SOURCE_DIR}/messages/CustomAtmStatusMsgPayload.h" +) + +# Link against SDK-provided Basilisk minimal libs (if your plugin needs them) + diff --git a/examples/custom-atm-plugin/custom_atm/__init__.py b/examples/custom-atm-plugin/custom_atm/__init__.py new file mode 100644 index 0000000..f4f66f8 --- /dev/null +++ b/examples/custom-atm-plugin/custom_atm/__init__.py @@ -0,0 +1,30 @@ +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +import sys +from Basilisk.architecture import cSysModel as _cSysModel + +# IMPORTANT: register cSysModel under its short name *before* importing the +# C extension below. The SWIG-generated wrapper does `import cSysModel` at +# class-definition time (module load), not at instantiation time. Without +# this line the import fails with a confusing ModuleNotFoundError. +sys.modules.setdefault("cSysModel", _cSysModel) + +from . import customExponentialAtmosphere + +__all__ = ["customExponentialAtmosphere"] diff --git a/examples/custom-atm-plugin/messages/CustomAtmStatusMsgPayload.h b/examples/custom-atm-plugin/messages/CustomAtmStatusMsgPayload.h new file mode 100644 index 0000000..f03e7f3 --- /dev/null +++ b/examples/custom-atm-plugin/messages/CustomAtmStatusMsgPayload.h @@ -0,0 +1,36 @@ +/* + ISC License + + Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + double density; //!< kg/m^3 + double scaleHeight; //!< m + int32_t modelValid; //!< 1 = ok, 0 = invalid +} CustomAtmStatusMsgPayload; + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/examples/custom-atm-plugin/pyproject.toml b/examples/custom-atm-plugin/pyproject.toml new file mode 100644 index 0000000..2180218 --- /dev/null +++ b/examples/custom-atm-plugin/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["scikit-build-core>=0.9.3", "numpy>=1.24"] +build-backend = "scikit_build_core.build" + +[project] +name = "bsk-plugin-exponential-atmosphere" +version = "0.1.0" + +[tool.scikit-build] +wheel.packages = ["custom_atm"] + +[tool.pytest.ini_options] +addopts = ["--import-mode=importlib"] diff --git a/examples/custom-atm-plugin/src/customExponentialAtmosphere.cpp b/examples/custom-atm-plugin/src/customExponentialAtmosphere.cpp new file mode 100644 index 0000000..3763cf7 --- /dev/null +++ b/examples/custom-atm-plugin/src/customExponentialAtmosphere.cpp @@ -0,0 +1,88 @@ +/* + ISC License + + Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + + +#include "customExponentialAtmosphere.h" + +/*! The constructor method initializes the dipole parameters to zero, resuling in a zero magnetic field result by default. + + */ +CustomExponentialAtmosphere::CustomExponentialAtmosphere() +{ + //! - Set the default atmospheric properties to yield a zero response + this->baseDensity = 0.0; // [T] + this->scaleHeight = 1.0; // [m] + this->planetRadius = 0.0; // [m] + this->localTemp = 1.0; // [K] + + return; +} + +/*! Empty destructor method. + + */ +CustomExponentialAtmosphere::~CustomExponentialAtmosphere() +{ + return; +} + +/*! This method is evaluates the centered dipole magnetic field model. + @param msg magnetic field message structure + @param currentTime current time (s) + + */ +void CustomExponentialAtmosphere::evaluateAtmosphereModel(AtmoPropsMsgPayload* msg, double currentTime) +{ + (void)currentTime; + + static bool firstCall = true; + if (firstCall) { + this->bskLogger.bskLog(BSK_INFORMATION, + "ExponentialAtmosphere (plugin): model active; using AtmosphereBase altitude."); + firstCall = false; + } + + bool statusApplied = false; + if (this->atmStatusInMsg_.isLinked()) { + const CustomAtmStatusMsgPayload status = this->atmStatusInMsg_(); // payload copy + if (status.modelValid) { + this->baseDensity = status.density; + this->scaleHeight = status.scaleHeight; + statusApplied = true; + } else { + this->bskLogger.bskLog(BSK_WARNING, + "ExponentialAtmosphere (plugin): CustomAtmStatusMsgPayload invalid; ignoring."); + } + } + + const double exponent = -(this->orbitAltitude) / this->scaleHeight; + msg->neutralDensity = this->baseDensity * std::exp(exponent); + msg->localTemp = this->localTemp; + + if (statusApplied) { + this->bskLogger.bskLog(BSK_INFORMATION, + "ExponentialAtmosphere (plugin): applied status msg; alt=%.3g m rho=%.3g", + this->orbitAltitude, msg->neutralDensity); + } +} + +void CustomExponentialAtmosphere::connectAtmStatus(Message* msg) +{ + this->atmStatusInMsg_.subscribeTo(msg); +} diff --git a/examples/custom-atm-plugin/src/customExponentialAtmosphere.h b/examples/custom-atm-plugin/src/customExponentialAtmosphere.h new file mode 100644 index 0000000..f480ea3 --- /dev/null +++ b/examples/custom-atm-plugin/src/customExponentialAtmosphere.h @@ -0,0 +1,47 @@ +/* + ISC License + + Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#pragma once + +#include "simulation/environment/_GeneralModuleFiles/atmosphereBase.h" +#include "architecture/utilities/bskLogging.h" +#include "architecture/messaging/messaging.h" // ReadFunctor / Message +#include "CustomAtmStatusMsgPayload.h" + +/*! @brief exponential atmosphere model (plugin example) */ +class CustomExponentialAtmosphere : public AtmosphereBase +{ +public: + CustomExponentialAtmosphere(); + ~CustomExponentialAtmosphere(); + + // Plugin-defined input message wiring (idiomatic Basilisk pattern) + void connectAtmStatus(Message* msg); + +private: + void evaluateAtmosphereModel(AtmoPropsMsgPayload* msg, double currentTime) override; + + ReadFunctor atmStatusInMsg_; + +public: + double baseDensity; //!< [kg/m^3] Density at h=0 + double scaleHeight; //!< [m] Exponential characteristic height + double localTemp = 293.0; //!< [K] Local atmospheric temperature (constant) + BSKLogger bskLogger; //!< -- BSK Logging +}; diff --git a/examples/custom-atm-plugin/swig/customExponentialAtmosphere.i b/examples/custom-atm-plugin/swig/customExponentialAtmosphere.i new file mode 100644 index 0000000..979f13b --- /dev/null +++ b/examples/custom-atm-plugin/swig/customExponentialAtmosphere.i @@ -0,0 +1,69 @@ +/* + ISC License + + Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + + +%module customExponentialAtmosphere + +%include "architecture/utilities/bskException.swg" +%default_bsk_exception(); + +%{ + #include "customExponentialAtmosphere.h" +%} + +%pythoncode %{ +from Basilisk.architecture.swig_common_model import * +%} +%include "swig_conly_data.i" +%include "std_vector.i" +%include "std_string.i" + +// IMPORTANT: use %import (not %include) for Basilisk base-class modules. +// +// %import tells SWIG "these types live in an existing Python module — do not +// re-wrap them here." The generated Python class for this plugin will then +// inherit from Basilisk's cSysModel.SysModel, which is what Basilisk's +// simulation task manager expects when you call AddModelToTask(). +// +// Using %include instead creates a *separate* SysModel type inside this +// module that is invisible to Basilisk's type system, causing a confusing +// runtime TypeError even though the C++ inheritance is correct. +%import "sys_model.i" + +// Intermediate base classes that are NOT exposed by any Basilisk Python +// module can still be %included — SWIG wraps them locally and they inherit +// from the imported SysModel above, keeping the full chain intact. +%include "simulation/environment/_GeneralModuleFiles/atmosphereBase.h" +%include "customExponentialAtmosphere.h" + +%include "architecture/msgPayloadDefC/SpicePlanetStateMsgPayload.h" +struct SpicePlanetStateMsg_C; +%include "architecture/msgPayloadDefC/SCStatesMsgPayload.h" +struct SCStatesMsg_C; +%include "architecture/msgPayloadDefC/AtmoPropsMsgPayload.h" +struct AtmoPropsMsg_C; + +// Plugin-defined message +%include "CustomAtmStatusMsgPayload.h" +struct CustomAtmStatusMsg_C; + +%pythoncode %{ +import sys +protectAllClasses(sys.modules[__name__]) +%} diff --git a/examples/custom-atm-plugin/test_atm_plugin.py b/examples/custom-atm-plugin/test_atm_plugin.py new file mode 100644 index 0000000..a0f3e26 --- /dev/null +++ b/examples/custom-atm-plugin/test_atm_plugin.py @@ -0,0 +1,145 @@ +""" +Integration test for the custom-atm-plugin example. + +Requires: + - bsk-sdk installed (provides the CMake helpers and SDK headers) + - custom_atm wheel built and installed (see example-plugins/custom-atm-plugin) + - Basilisk installed (provides SimulationBaseClass, messaging, etc.) + +Skip gracefully if either optional dependency is absent so the smoke test +suite still passes in environments that only have bsk-sdk installed. +""" + +from __future__ import annotations + +import math + +import pytest + +basilisk = pytest.importorskip("Basilisk", reason="Basilisk not installed") +custom_atm = pytest.importorskip("custom_atm", reason="custom_atm plugin not installed") + +from Basilisk.architecture import messaging, bskLogging # noqa: E402 +from Basilisk.utilities import SimulationBaseClass, macros # noqa: E402 +from custom_atm import customExponentialAtmosphere # noqa: E402 +from custom_atm.messaging import CustomAtmStatusMsg, CustomAtmStatusMsgPayload # noqa: E402 + + +def _window_density(log) -> float: + vals = list(log.neutralDensity) + assert vals, "No density samples were recorded" + return float(max(vals)) + + +@pytest.fixture() +def sim_env(): + """Set up a minimal Basilisk sim with the custom atmosphere plugin.""" + sim = SimulationBaseClass.SimBaseClass() + sim.bskLogger.setLogLevel(bskLogging.BSK_WARNING) + + proc = sim.CreateNewProcess("proc") + dt = macros.sec2nano(1.0) + proc.addTask(sim.CreateNewTask("task", dt)) + + atmosphere = customExponentialAtmosphere.CustomExponentialAtmosphere() + atmosphere.planetRadius = 6_371_000.0 # m + atmosphere.envMinReach = -1.0 + atmosphere.envMaxReach = -1.0 + atmosphere.baseDensity = 1.225 # kg/m^3 + atmosphere.scaleHeight = 8_500.0 # m + atmosphere.localTemp = 293.0 # K + + sim.AddModelToTask("task", atmosphere) + + sc_pl = messaging.SCStatesMsgPayload() + sc_pl.r_BN_N = [atmosphere.planetRadius + 400_000.0, 0.0, 0.0] + sc_msg = messaging.SCStatesMsg() + atmosphere.addSpacecraftToModel(sc_msg) + sc_msg.write(sc_pl) + # Keep Python message objects alive for the full fixture lifetime. If these + # go out of scope, Basilisk can read them as unwritten/default and return + # zero atmosphere outputs in tests. + sim._test_sc_msg = sc_msg + sim._test_sc_payload = sc_pl + + log = atmosphere.envOutMsgs[0].recorder() + sim.AddModelToTask("task", log) + + sim.InitializeSimulation() + # Re-write once after init so the subscriber is unambiguously marked written + # for the first execution step across Basilisk/Python wrapper variations. + sc_msg.write(sc_pl) + return sim, atmosphere, log, dt + + +def test_plugin_instantiates(): + atm = customExponentialAtmosphere.CustomExponentialAtmosphere() + assert atm is not None + + +def test_density_at_400km(sim_env): + sim, atmosphere, log, dt = sim_env + sim.ConfigureStopTime(dt) + sim.ExecuteSimulation() + + rho = _window_density(log) + # Analytic: rho = 1.225 * exp(-400e3 / 8500) ≈ 1.53e-23 + expected = 1.225 * math.exp(-400_000.0 / 8_500.0) + assert rho == pytest.approx(expected, rel=1e-6, abs=0.0) + + +def test_density_positive(sim_env): + sim, atmosphere, log, dt = sim_env + sim.ConfigureStopTime(dt) + sim.ExecuteSimulation() + + assert _window_density(log) > 0.0 + + +def test_status_message_updates_density(sim_env): + sim, atmosphere, log, dt = sim_env + + status_pl = CustomAtmStatusMsgPayload() + status_pl.density = 2.0 + status_pl.scaleHeight = 8_500.0 + status_pl.modelValid = 1 + status_msg = CustomAtmStatusMsg().write(status_pl) + atmosphere.connectAtmStatus(status_msg) + + sim.ConfigureStopTime(dt) + sim.ExecuteSimulation() + + # With baseDensity overridden to 2.0, density should be ~2x the default + rho = _window_density(log) + expected = 2.0 * math.exp(-400_000.0 / 8_500.0) + assert rho == pytest.approx(expected, rel=1e-6, abs=0.0) + + +def test_invalid_status_message_ignored(sim_env): + sim, atmosphere, log, dt = sim_env + + # modelValid=0 — should be ignored, density stays at default baseDensity + status_pl = CustomAtmStatusMsgPayload() + status_pl.density = 999.0 + status_pl.scaleHeight = 1.0 + status_pl.modelValid = 0 + status_msg = CustomAtmStatusMsg().write(status_pl) + atmosphere.connectAtmStatus(status_msg) + + sim.ConfigureStopTime(dt) + sim.ExecuteSimulation() + + rho = _window_density(log) + expected = 1.225 * math.exp(-400_000.0 / 8_500.0) + assert rho == pytest.approx(expected, rel=1e-6, abs=0.0) + + +def test_recorder_grows_each_step(sim_env): + sim, atmosphere, log, dt = sim_env + + for step in range(1, 4): + sim.ConfigureStopTime(step * dt) + sim.ExecuteSimulation() + # Basilisk recorder writes an initial sample at sim initialization, + # then one additional sample per executed step. + assert len(log.neutralDensity) == step + 1 diff --git a/external/basilisk b/external/basilisk new file mode 160000 index 0000000..7c7cd7b --- /dev/null +++ b/external/basilisk @@ -0,0 +1 @@ +Subproject commit 7c7cd7b2a7b8bbdcd0bacf9f8360351ff5894c76 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d5ef068 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["scikit-build-core>=0.9.3"] +build-backend = "build_backend" +backend-path = ["."] + +[project] +name = "bsk-sdk" +version = "1.0.0" +description = "Vendored Basilisk SDK headers, runtime, and CMake integration for plugin authors" +readme = "README.md" +license = { text = "ISC" } +requires-python = ">=3.9" +dependencies = [ + # Plugin authors compile SWIG extensions against bsk. The pip swig package + # provides a self-contained binary on all platforms and is detected + # automatically by bsk_add_swig_module.cmake via the Python interpreter. + # + # SWIG 4.4.0 bumped SWIG_RUNTIME_VERSION from "4" to "5", changing the + # capsule name from swig_runtime_data4 to swig_runtime_data5. bsk is built + # with SWIG 4.3.1 (runtime version 4), so plugins must also use a <4.4 + # SWIG to share the same type table and make cross-module type casts work. + "swig>=4.0,<4.4", +] + +[project.entry-points."cmake.prefix"] +bsk-sdk = "bsk_sdk" + +[tool.scikit-build] +wheel.packages = ["bsk_sdk"] +wheel.py-api = "cp39" +wheel.install-dir = "bsk_sdk" + +sdist.include = [ + "CMakeLists.txt", + "cmake/**", + "src/bsk_sdk/include/**", + "src/bsk_sdk/include_compat/**", + "src/bsk_sdk/arch_min/**", + "src/bsk_sdk/runtime_min/**", + "src/bsk_sdk/swig/**", + "tools/**", + "src/bsk_sdk/__init__.py", +] + +sdist.exclude = [ + "**/__pycache__/**", + "**/*.pyc", +] + +[tool.scikit-build.cmake] +version = ">=3.18" + +# --------------------------------------------------------------------------- +# cibuildwheel +# --------------------------------------------------------------------------- +[tool.cibuildwheel] +# Build CPython 3.9–3.13 only; skip PyPy and 32-bit Linux. +build = "cp39-* cp310-* cp311-* cp312-* cp313-*" +skip = ["*-musllinux*", "*-manylinux_i686*", "*-win32"] + +# Eigen3 is fetched automatically by CMake (FetchContent) if not found locally — +# no before-all installation step needed on any platform. diff --git a/src/bsk_sdk/__init__.py b/src/bsk_sdk/__init__.py new file mode 100644 index 0000000..1522676 --- /dev/null +++ b/src/bsk_sdk/__init__.py @@ -0,0 +1,46 @@ +from importlib import resources +from pathlib import Path + + +def package_root() -> Path: + return Path(resources.files(__package__)) + + +def cmake_config_dir() -> str: + return str(package_root() / "lib" / "cmake" / "bsk-sdk") + + +def include_dir() -> str: + return str(package_root() / "include") + + +def include_dirs() -> list[str]: + root = package_root() + return [ + str(root / "include"), + str(root / "include" / "Basilisk"), + str(root / "include" / "compat"), + ] + + +def swig_dir() -> str: + return str(package_root() / "swig") + + +def tools_dir() -> str: + return str(package_root() / "tools") + + +def msg_autosource_dir() -> str: + return str(package_root() / "tools" / "msgAutoSource") + + +__all__ = [ + "package_root", + "cmake_config_dir", + "include_dir", + "include_dirs", + "swig_dir", + "tools_dir", + "msg_autosource_dir", +] diff --git a/src/bsk_sdk/include/bsk/sdk.hpp b/src/bsk_sdk/include/bsk/sdk.hpp new file mode 100644 index 0000000..6f70f09 --- /dev/null +++ b/src/bsk_sdk/include/bsk/sdk.hpp @@ -0,0 +1 @@ +#pragma once diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..b7b6da5 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,56 @@ +""" +Smoke tests for the installed bsk-sdk wheel. + +Verifies that the package is importable, all advertised paths exist on disk, +and the CMake config directory contains the expected files. +""" + +from __future__ import annotations + +from pathlib import Path + +import bsk_sdk + + +def test_package_root_exists() -> None: + root = bsk_sdk.package_root() + assert Path(root).is_dir(), f"package_root() does not exist: {root}" + + +def test_include_dirs_exist() -> None: + for d in bsk_sdk.include_dirs(): + assert Path(d).is_dir(), f"include dir missing: {d}" + + +def test_swig_dir_exists() -> None: + assert Path(bsk_sdk.swig_dir()).is_dir() + + +def test_tools_dir_exists() -> None: + assert Path(bsk_sdk.tools_dir()).is_dir() + + +def test_cmake_config_dir_exists() -> None: + assert Path(bsk_sdk.cmake_config_dir()).is_dir() + + +def test_cmake_config_files_present() -> None: + config_dir = Path(bsk_sdk.cmake_config_dir()) + assert (config_dir / "bsk-sdkConfig.cmake").exists() + assert (config_dir / "bsk-sdkConfigVersion.cmake").exists() + assert (config_dir / "bsk-sdkTargets.cmake").exists() + + +def test_key_headers_present() -> None: + include_root = Path(bsk_sdk.include_dir()) / "Basilisk" + expected = [ + "architecture/_GeneralModuleFiles/sys_model.h", + "architecture/messaging/messaging.h", + "architecture/utilities/linearAlgebra.h", + "architecture/utilities/gauss_markov.h", + "simulation/dynamics/_GeneralModuleFiles/dynamicEffector.h", + "simulation/dynamics/_GeneralModuleFiles/dynamicObject.h", + ] + for rel in expected: + p = include_root / rel + assert p.exists(), f"Expected header missing from SDK: {p}" diff --git a/tools/_sync_paths.py b/tools/_sync_paths.py new file mode 100644 index 0000000..c545014 --- /dev/null +++ b/tools/_sync_paths.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path + +BSK_BASILISK_ROOT_ENV = "BSK_BASILISK_ROOT" +SDK_REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_BASILISK_SUBMODULE_DIR = SDK_REPO_ROOT / "external" / "basilisk" + + +def resolve_basilisk_root(basilisk_root_arg: str | None) -> Path: + if basilisk_root_arg: + return Path(basilisk_root_arg).expanduser().resolve() + + env_root = os.environ.get(BSK_BASILISK_ROOT_ENV) + if env_root: + return Path(env_root).expanduser().resolve() + + if DEFAULT_BASILISK_SUBMODULE_DIR.exists(): + return DEFAULT_BASILISK_SUBMODULE_DIR.resolve() + + sibling = SDK_REPO_ROOT.parent / "basilisk" + if sibling.exists(): + return sibling.resolve() + + raise RuntimeError( + "Could not locate Basilisk repository root. " + f"Set --basilisk-root or {BSK_BASILISK_ROOT_ENV}, " + "or initialize external/basilisk submodule." + ) + + +def resolve_basilisk_src_root(basilisk_root_arg: str | None) -> Path: + return resolve_basilisk_root(basilisk_root_arg) / "src" diff --git a/tools/msgAutoSource/cMsgCInterfacePy.i.in b/tools/msgAutoSource/cMsgCInterfacePy.i.in new file mode 100644 index 0000000..812db56 --- /dev/null +++ b/tools/msgAutoSource/cMsgCInterfacePy.i.in @@ -0,0 +1,41 @@ +%{ +#include "cMsgCInterface/{type}_C.h" +#include "architecture/messaging/messaging.h" +%} + +%include "cMsgCInterface/{type}_C.h" +%include "architecture/messaging/msgHeader.h" + +%extend {type}_C { + Recorder<{type}Payload> recorder(uint64_t timeDiff = 0) { + self->header.isLinked = 1; + return Recorder<{type}Payload>{static_cast(self), timeDiff}; + } + +%pythoncode %{ + +def subscribeTo(self, source): + """Subscribe to another {type} message (same type only).""" + if not isinstance(source, self.__class__): + raise TypeError(f"{self.__class__.__name__}.subscribeTo expects same message type") + {type}_C_subscribe(self, source) + return self + +def unsubscribe(self): + """Unsubscribe from the connected message (noop if none).""" + {type}_unsubscribe(self) + return self + +def write(self, payload, time=0, moduleID=0): + """Write payload to this message and return self (chainable).""" + # Optional: only if your C side requires an author registration + {type}_C_addAuthor(self, self) + {type}_C_write(payload, self, moduleID, time) + return self + +def read(self): + """Read payload from this message.""" + return {type}_C_read(self) + +%} +}; diff --git a/tools/msgAutoSource/generatePackageInit.py b/tools/msgAutoSource/generatePackageInit.py new file mode 100644 index 0000000..b4f509f --- /dev/null +++ b/tools/msgAutoSource/generatePackageInit.py @@ -0,0 +1,79 @@ +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +from __future__ import annotations +import sys +from pathlib import Path + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + print( + "Usage:\n" + " generatePackageInit.py [headerDir2 ...]\n", + file=sys.stderr, + ) + return 2 + + module_output_dir = Path(argv[1]).resolve() + module_output_dir.mkdir(parents=True, exist_ok=True) + + # WORKING_DIRECTORY is set to msgAutoSource, and the header dirs are passed as relative paths + cwd = Path.cwd() + + header_dirs: list[Path] = [] + for a in argv[2:]: + p = Path(a) + if not p.is_absolute(): + p = (cwd / p).resolve() + header_dirs.append(p) + + init_py = module_output_dir / "__init__.py" + + lines: list[str] = [] + lines.append("# Auto-generated. Do not edit.\n") + + for header_dir in header_dirs: + if not header_dir.exists(): + raise FileNotFoundError(f"Header input path not found: {header_dir}") + + for file_path in sorted(header_dir.iterdir()): + if file_path.suffix.lower() not in (".h", ".hpp"): + continue + + class_name = file_path.stem + lines.append( + f"from Basilisk.architecture.messaging.{class_name} import *\n" + ) + + # Deduplicate imports + seen = set() + deduped: list[str] = [] + for ln in lines: + if ln.startswith("from "): + if ln in seen: + continue + seen.add(ln) + deduped.append(ln) + + init_py.write_text("".join(deduped), encoding="utf-8", newline="\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tools/msgAutoSource/generateSWIGModules.py b/tools/msgAutoSource/generateSWIGModules.py new file mode 100644 index 0000000..62e8e00 --- /dev/null +++ b/tools/msgAutoSource/generateSWIGModules.py @@ -0,0 +1,422 @@ +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +from __future__ import annotations +import re +from typing import Optional, Tuple, Callable, List +import xml.etree.ElementTree as ET +import argparse +from pathlib import Path + +try: + from distutils.util import strtobool +except ImportError: + # Python 3.12+ removed distutils; provide a fallback + def strtobool(val: str) -> int: + """Convert a string representation of truth to true (1) or false (0). + + True values are y, yes, t, true, on and 1; false values are n, no, + f, false, off and 0. Raises ValueError if val is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return 1 + elif val in ("n", "no", "f", "false", "off", "0"): + return 0 + else: + raise ValueError(f"invalid truth value {val!r}") + + +# Maps numeric C/C++ types to numpy dtype enum names +C_TYPE_TO_NPY_ENUM = { + # Signed integers + "int8_t": "NPY_INT8", + "short": "NPY_INT16", + "short int": "NPY_INT16", + "signed short": "NPY_INT16", + "signed short int": "NPY_INT16", + "int16_t": "NPY_INT16", + "int": "NPY_INT32", + "signed int": "NPY_INT32", + "int32_t": "NPY_INT32", + "long": "NPY_LONG", + "long int": "NPY_LONG", + "signed long": "NPY_LONG", + "signed long int": "NPY_LONG", + "long long": "NPY_INT64", + "long long int": "NPY_INT64", + "signed long long": "NPY_INT64", + "signed long long int": "NPY_INT64", + "int64_t": "NPY_INT64", + # Unsigned integers + "uint8_t": "NPY_UINT8", + "unsigned short": "NPY_UINT16", + "unsigned short int": "NPY_UINT16", + "uint16_t": "NPY_UINT16", + "unsigned": "NPY_UINT32", + "unsigned int": "NPY_UINT32", + "uint32_t": "NPY_UINT32", + "unsigned long": "NPY_ULONG", + "unsigned long int": "NPY_ULONG", + "unsigned long long": "NPY_UINT64", + "unsigned long long int": "NPY_UINT64", + "uint64_t": "NPY_UINT64", + # Platform-size signed/unsigned integers + "intptr_t": "NPY_INTP", + "uintptr_t": "NPY_UINTP", + "ptrdiff_t": "NPY_INTP", + "ssize_t": "NPY_INTP", + "size_t": "NPY_UINTP", + # Floating point types + "float16": "NPY_FLOAT16", + "half": "NPY_FLOAT16", + "float": "NPY_FLOAT32", + "float32": "NPY_FLOAT32", + "double": "NPY_FLOAT64", + "float64": "NPY_FLOAT64", + "long double": "NPY_LONGDOUBLE", +} + + +def cleanSwigType(cppType: str) -> str: + """ + Cleans and normalizes a C++ template type string extracted from SWIG-generated XML, + restoring valid syntax and formatting for use in generated C++ or Python bindings. + + This function removes redundant parentheses and correctly reconstructs: + - Nested template structures (e.g., std::vector>) + - Function signatures within std::function (e.g., std::function) + - Multi-word C++ types (e.g., unsigned long long, const float&) + - Pointer and reference types (e.g., T*, T&, T&&) + - Fixed-size C-style arrays (e.g., int[10], const float(&)[3]) + + Parameters: + cppType (str): The raw type string from SWIG XML, possibly containing spurious + parentheses and HTML-escaped characters (<, >). + + Returns: + str: A cleaned-up and properly formatted C++ type string. + """ + import html + import re + + # Decode any XML-escaped characters (e.g., < to <) + cppType = html.unescape(cppType) + + # Tokenize while preserving C++ symbols + tokens = re.findall(r"\w+::|::|\w+|<|>|,|\(|\)|\[|\]|\*|&|&&|\S", cppType) + + def stripParensIfWrapped(typeStr: str) -> str: + """ + Remove surrounding parentheses if they are redundant and balanced. + + Args: + typeStr: A potentially parenthesized type string + + Returns: + str: The string with parentheses removed if they are unnecessary + """ + typeStr = typeStr.strip() + if typeStr.startswith("(") and typeStr.endswith(")"): + depth = 0 + for i, ch in enumerate(typeStr): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + # If we close early, parentheses are internal – keep them + if depth == 0 and i != len(typeStr) - 1: + return typeStr + return typeStr[1:-1].strip() + return typeStr + + def isWord(token: str) -> bool: + """Return True if the token is a C++ identifier (e.g., 'int', 'const')""" + return re.fullmatch(r"\w+", token) is not None + + def parseType(index: int) -> Tuple[str, int]: + """ + Recursively parses a C++ type expression from tokens. + + Args: + index: Current token index + + Returns: + tuple: + str: parsed type string + int: next token index + """ + parts = [] + while index < len(tokens): + token = tokens[index] + + if token == "<": + index += 1 + inner, index = parseBracketBlock(index, "<", ">", parseType) + parts.append(f"<{inner}>") + + elif token == "(": + index += 1 + inner, index = parseBracketBlock(index, "(", ")", parseType) + parts.append(f"({inner})") + + elif token in [")", ">"]: + break + + elif token == ",": + index += 1 + break + + else: + # Add space only between adjacent words + if parts: + prev = parts[-1] + if isWord(prev) and isWord(token): + parts.append(" ") + parts.append(token) + index += 1 + + return "".join(parts), index + + def parseBracketBlock( + index: int, + openSym: str, + closeSym: str, + parseFunc: Callable[[int], Tuple[str, int]], + ) -> Tuple[str, int]: + """ + Parses a bracketed block like <...> or (...) or [...] with nested content. + + Args: + index: Current token index (after the opening symbol) + openSym: Opening symbol, e.g. '<' + closeSym: Closing symbol, e.g. '>' + parseFunc: Recursive parse function (e.g., parseType) + + Returns: + tuple: + str: parsed type string + int: next token in + """ + items: List[str] = [] + while index < len(tokens): + if tokens[index] == closeSym: + cleaned = [stripParensIfWrapped(i) for i in items] + return ", ".join(cleaned), index + 1 + elif tokens[index] == ",": + index += 1 # skip separator + else: + item, index = parseFunc(index) + items.append(item) + return ", ".join(items), index + + cleaned, _ = parseType(0) + return cleaned.strip() + + +def parseSwigDecl(decl: str): + """ + Parses a SWIG `decl` string and converts it into components of a C++ declarator. + + SWIG represents C++ type modifiers (pointers, references, arrays, functions) using a dot-delimited + postfix syntax attached to the base type. This function interprets those tokens and produces + the corresponding C++-style declarator suffix elements. + + SWIG encoding conventions: + - `p.` : pointer (adds a `*`) + - `r.` : reference (adds a `&`) + - `a(N).` : fixed-size array of N elements (adds `[N]`) + - `f().` : function type (currently ignored by this parser) + + Modifier order is postfix but applies from inside-out: + Example: `a(3).p.` means "pointer to array of 3" -> `*[3]` + `p.a(3).` means "array of 3 pointers" -> `[3]*` + + Parameters: + decl (str): The SWIG-encoded declarator string. Examples: + - "p." -> pointer + - "r." -> reference + - "a(5)." -> array of 5 elements + - "a(2).a(2).p.p." -> pointer to pointer to 2x2 array + + Returns: + tuple: + pointerPart (str): a string of '*' characters for pointer depth (e.g., '**') + referencePart (str): '&' if this is a reference, else '' + arrayParts (list[str]): array dimensions as strings, ordered from outermost to innermost. + For example, ['2', '3'] for `a(2).a(3).` means `[2][3]`. + + Example: + >>> parseSwigDecl("a(3).p.") + ('*', '', ['3']) # pointer to array of 3 -> '*[3]' + + >>> parseSwigDecl("p.a(3).") + ('*', '', ['3']) # array of 3 pointers -> '[3]*' + + >>> parseSwigDecl("p.p.a(2).a(2).r.") + ('**', '&', ['2', '2']) # reference to pointer to pointer to 2x2 array + """ + pointerPart = "" + referencePart = "" + arrayParts = [] + + # Match each declarator component + tokens = re.findall(r"(a\([^)]+\)|p\.|r\.|f\(\)\.)", decl) + + for token in tokens: + if token.startswith("a("): + # Array: a(N) -> N + size = re.match(r"a\(([^)]+)\)", token).group(1) + arrayParts.append(size) + elif token == "p.": + pointerPart += "*" + elif token == "r.": + referencePart = "&" # References appear after pointer + # Note: We skip f(). (function pointer) for now + + return pointerPart, referencePart, arrayParts + + +def parseSwigXml( + xmlPath: str, targetStructName: str, cpp: bool, recorderPropertyRollback: bool +): + """ + Parses a SWIG-generated XML file and emits RECORDER_PROPERTY macros + for all struct/class member fields. + + Parameters: + xmlPath (str): Path to the XML file generated with `swig -xml` + targetStructName (str): Name of the payload (e.g. `MTBMsgPayload`). + cpp (bool): whether this is a C++ payload (we need to be extra + careful with the type since it might be templated.) + recorderPropertyRollback (bool): If true, non-numeric properties + are not given a special RECORDER_PROPERTY macro, thus recovering + the legacy output format for these fields. + + Returns: + str: macro declarations to be pasted into `msgInterfacePy.i.in` + """ + result = "" + + tree = ET.parse(xmlPath) + root = tree.getroot() + + # Iterate over all classes (C++ classes and structs) + for classNode in root.findall(".//class"): + classAttrs = extractAttributeMap(classNode.find("attributelist")) + structName = classAttrs.get("name") or classAttrs.get("tdname") + if structName != targetStructName: + continue + + # Each field appears as a child with ismember="1" + for cdecl in classNode.findall("cdecl"): + fieldAttrs = extractAttributeMap(cdecl.find("attributelist")) + if fieldAttrs.get("ismember") != "1": + continue # Skip non-member declarations + + fieldName = fieldAttrs.get("name") + baseType = fieldAttrs.get("type") + decl = fieldAttrs.get("decl", "") + + if not fieldName or not baseType: + continue + + if cpp: + baseType = cleanSwigType(baseType) + + typePointerPart, typeReferencePart, typeArrayParts = parseSwigDecl(decl) + typeWithPointerRef = f"{baseType}{typePointerPart}{typeReferencePart}" + + if typeWithPointerRef in C_TYPE_TO_NPY_ENUM and len(typeArrayParts) < 3: + npyType = C_TYPE_TO_NPY_ENUM[typeWithPointerRef] + macroName = f"RECORDER_PROPERTY_NUMERIC_{len(typeArrayParts)}" + result += f"{macroName}({targetStructName}, {fieldName}, {typeWithPointerRef}, {npyType});\n" + elif not recorderPropertyRollback: + fullType = ( + f"{typeWithPointerRef}{''.join(f'[{i}]' for i in typeArrayParts)}" + ) + result += f"RECORDER_PROPERTY({targetStructName}, {fieldName}, ({fullType}));\n" + + return result + + +def extractAttributeMap(attributeListNode: Optional[ET.Element]): + """ + Converts an XML node into a Python dict. + + Parameters: + attributeListNode (Element | None): The element + + Returns: + dict[str, str]: Mapping of attribute name -> value + """ + if attributeListNode is None: + return {} + return { + attr.attrib["name"]: attr.attrib["value"] + for attr in attributeListNode.findall("attribute") + if "name" in attr.attrib and "value" in attr.attrib + } + + +def _bool01(s: str) -> bool: + return bool(int(s)) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("moduleOutputPath") + ap.add_argument("headerInputPath") + ap.add_argument("payloadTypeName") + ap.add_argument("baseDir") + ap.add_argument("generateCInfo") + ap.add_argument("xmlWrapPath") + ap.add_argument("recorderPropertyRollback") + args = ap.parse_args() + + out_path = Path(args.moduleOutputPath) + struct_type = args.payloadTypeName.split("Payload")[0] + + generate_c = bool(strtobool(args.generateCInfo)) # supports True/False/1/0 etc + rollback = _bool01(args.recorderPropertyRollback) + + swig_template = Path("msgInterfacePy.i.in").read_text(encoding="utf-8") + swig_c_template = Path("cMsgCInterfacePy.i.in").read_text(encoding="utf-8") + + extra = parseSwigXml( + args.xmlWrapPath, + args.payloadTypeName, + cpp=not generate_c, + recorderPropertyRollback=rollback, + ) + + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8", newline="\n") as f: + f.write( + swig_template.format( + type=struct_type, baseDir=args.baseDir, extraContent=extra + ) + ) + if generate_c: + f.write(swig_c_template.format(type=struct_type)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/msgAutoSource/msgInterfacePy.i.in b/tools/msgAutoSource/msgInterfacePy.i.in new file mode 100644 index 0000000..cc1d37b --- /dev/null +++ b/tools/msgAutoSource/msgInterfacePy.i.in @@ -0,0 +1,103 @@ +%module(threads="1") {type}Payload +%{{ + #include "{baseDir}/{type}Payload.h" + #include "architecture/messaging/messaging.h" + #include "architecture/msgPayloadDefC/ReconfigBurnInfoMsgPayload.h" + #include "architecture/msgPayloadDefC/RWConfigElementMsgPayload.h" + #include "architecture/msgPayloadDefC/THRConfigMsgPayload.h" + #include "simulation/dynamics/reactionWheels/reactionWheelSupport.h" + #include + #include + #include +%}} +%include "messaging/newMessaging.ih" + +%include "std_vector.i" +%include "std_string.i" +%include "_GeneralModuleFiles/swig_eigen.i" +%include "_GeneralModuleFiles/swig_conly_data.i" +%include "stdint.i" +%template(TimeVector) std::vector>; +%template(DoubleVector) std::vector>; +%template(StringVector) std::vector>; + +%include "architecture/utilities/macroDefinitions.h" +%include "fswAlgorithms/fswUtilities/fswDefinitions.h" +%include "simulation/dynamics/reactionWheels/reactionWheelSupport.h" +ARRAYASLIST(FSWdeviceAvailability, PyLong_FromLong, PyLong_AsLong) +STRUCTASLIST(CSSUnitConfigMsgPayload) +STRUCTASLIST(AccPktDataMsgPayload) +STRUCTASLIST(RWConfigElementMsgPayload) +STRUCTASLIST(CSSArraySensorMsgPayload) + +%include "messaging/messaging.h" +%include "_GeneralModuleFiles/sys_model.h" + +%array_functions(THRConfigMsgPayload, ThrustConfigArray); +%array_functions(RWConfigElementMsgPayload, RWConfigArray); +%array_functions(ReconfigBurnInfoMsgPayload, ReconfigBurnArray); + +%rename(__subscribe_to) subscribeTo; // we want the users to have a unified "subscribeTo" interface +%rename(__subscribe_to_C) subscribeToC; // we want the users to have a unified "subscribeTo" interface +%rename(__is_subscribed_to) isSubscribedTo; // we want the users to have a unified "isSubscribedTo" interface +%rename(__is_subscribed_to_C) isSubscribedToC; // we want the users to have a unified "isSubscribedTo" interface +%rename(__time_vector) times; // It's not really useful to give the user back a time vector +%rename(__timeWritten_vector) timesWritten; +%rename(__record_vector) record; + +%pythoncode %{{ +import numpy as np +%}}; +%include "{baseDir}/{type}Payload.h" +{extraContent} +INSTANTIATE_TEMPLATES({type}, {type}Payload, {baseDir}) +%template({type}OutMsgsVector) std::vector, std::allocator> >; +%template({type}OutMsgsPtrVector) std::vector*, std::allocator*> >; +%template({type}InMsgsVector) std::vector, std::allocator> >; +%pythoncode %{{ +if "{type}" == "EclipseMsg": + from . import _{type}Payload as _m + from Basilisk.utilities import deprecated + + # ----- Payload aliasing ----- + def _get_illum(self): + return _m.{type}Payload_shadowFactor_get(self) + + def _set_illum(self, v): + _m.{type}Payload_shadowFactor_set(self, v) + + {type}Payload.illuminationFactor = property(_get_illum, _set_illum) + + # Deprecate the old shadowFactor name + def _warn_get_shadow(self): + deprecated.deprecationWarn( + "EclipseMsgPayload.shadowFactor", + "2026/12/31", + "Use EclipseMsgPayload.illuminationFactor instead (0=fully eclipsed, 1=fully sunlit)." + ) + return _m.{type}Payload_shadowFactor_get(self) + + def _warn_set_shadow(self, v): + deprecated.deprecationWarn( + "EclipseMsgPayload.shadowFactor", + "2026/12/31", + "Use EclipseMsgPayload.illuminationFactor instead (0=fully eclipsed, 1=fully sunlit)." + ) + _m.{type}Payload_shadowFactor_set(self, v) + + {type}Payload.shadowFactor = property(_warn_get_shadow, _warn_set_shadow) + + # ----- Recorder aliasing so logs can use illuminationFactor ----- + try: + _orig_rec_getattr = EclipseMsgRecorder.__getattr__ + + def _rec_getattr_with_alias(self, name): + if name == "illuminationFactor": + # Delegate to the original implementation but ask for shadowFactor + return _orig_rec_getattr(self, "shadowFactor") + return _orig_rec_getattr(self, name) + + EclipseMsgRecorder.__getattr__ = _rec_getattr_with_alias + except Exception: + pass +%}} diff --git a/tools/sync_all.py b/tools/sync_all.py new file mode 100644 index 0000000..ae811ee --- /dev/null +++ b/tools/sync_all.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def run(cmd: list[str], cwd: Path) -> None: + print(f"\n==> {' '.join(cmd)}") + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def sync_basilisk_submodule(repo_root: Path) -> None: + run( + [ + "git", + "submodule", + "update", + "--init", + "--recursive", + "external/basilisk", + ], + cwd=repo_root, + ) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Run all bsk-sdk sync scripts in order.") + ap.add_argument( + "--sdk-tools-dir", + default=None, + help="Path to sdk/tools (defaults to this script's directory).", + ) + ap.add_argument( + "--basilisk-root", + default=None, + help="Path to Basilisk repository root (or set BSK_BASILISK_ROOT).", + ) + ap.add_argument( + "--python", + default=sys.executable, + help="Python executable to use (default: current interpreter).", + ) + ap.add_argument( + "--sync-submodules", + action="store_true", + help="Run 'git submodule update --init --recursive external/basilisk' first.", + ) + args = ap.parse_args() + + tools_dir = ( + Path(args.sdk_tools_dir).resolve() + if args.sdk_tools_dir + else Path(__file__).resolve().parent + ) + py = args.python + basilisk_root = args.basilisk_root + repo_root = tools_dir.parent + + if args.sync_submodules: + sync_basilisk_submodule(repo_root) + + scripts = [ + "sync_headers.py", + "sync_runtime.py", + "sync_sources.py", + "sync_swig.py", + ] + + for s in scripts: + p = tools_dir / s + if not p.exists(): + raise FileNotFoundError(f"Missing {p}") + cmd = [py, str(p)] + if basilisk_root: + cmd.extend(["--basilisk-root", basilisk_root]) + run(cmd, cwd=tools_dir) + + print("\nAll sync steps completed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sync_headers.py b/tools/sync_headers.py new file mode 100755 index 0000000..d739ae2 --- /dev/null +++ b/tools/sync_headers.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 + +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + + +""" +Synchronize a curated subset of Basilisk headers into the SDK package. + +Copies selected directories from the main Basilisk `src/` tree into: + + sdk/src/bsk_sdk/include/Basilisk/ + +Only the headers that plugin authors need to compile against are included — +not the full Basilisk source tree. Specifically: + + - architecture/_GeneralModuleFiles sys_model.h base class for all modules + - architecture/messaging message subscription/publication infrastructure + - architecture/utilities logging, linearAlgebra, gaussMarkov, moduleId, etc. + - architecture/msgPayloadDefC C message payload structs + - architecture/msgPayloadDefCpp C++ message payload structs + - fswAlgorithms/fswUtilities common FSW utility headers + - simulation/environment/_GeneralModuleFiles atmosphereBase.h (needed by runtime_min) + - simulation/dynamics/_GeneralModuleFiles dynamicEffector.h, dynamicObject.h base classes + - simulation/dynamics/reactionWheels RW base classes for custom RW plugin authors + +This script is intended to be run by Basilisk maintainers when updating the SDK. +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _sync_paths import SDK_REPO_ROOT, resolve_basilisk_src_root + +SDK_INCLUDE_ROOT = SDK_REPO_ROOT / "src" / "bsk_sdk" / "include" / "Basilisk" + +# Directories to vendor into the SDK, relative to src/. +DIRECTORIES = [ + "architecture/_GeneralModuleFiles", + "architecture/messaging", + "architecture/utilities", + "architecture/msgPayloadDefC", + "architecture/msgPayloadDefCpp", + "fswAlgorithms/fswUtilities", + "simulation/dynamics/_GeneralModuleFiles", + "simulation/dynamics/reactionWheels", + "simulation/environment/_GeneralModuleFiles", + "simulation/power/_GeneralModuleFiles", + "simulation/onboardDataHandling/_GeneralModuleFiles", +] + +# Things that must be excluded from the SDK +IGNORE_PATTERNS = [ + "_UnitTest", + "_Documentation", + "__pycache__", + "*.swg", + "*.i", + "*.py", + "*.cpp", + "*.c", +] + + +def copy_tree(src: Path, dest: Path) -> None: + """Replace dest with a filtered copy of src.""" + if dest.exists(): + shutil.rmtree(dest) + + dest.parent.mkdir(parents=True, exist_ok=True) + + shutil.copytree( + src, + dest, + ignore=shutil.ignore_patterns(*IGNORE_PATTERNS), + ) + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Sync Basilisk public headers into bsk-sdk/src/bsk_sdk/include/Basilisk" + ) + ap.add_argument( + "--basilisk-root", + default=None, + help="Path to Basilisk repository root (or set BSK_BASILISK_ROOT).", + ) + args = ap.parse_args() + + src_root = resolve_basilisk_src_root(args.basilisk_root) + + if not src_root.exists(): + raise RuntimeError(f"Expected Basilisk src directory not found: {src_root}") + + SDK_INCLUDE_ROOT.mkdir(parents=True, exist_ok=True) + + for relative in DIRECTORIES: + src_dir = src_root / relative + dest_dir = SDK_INCLUDE_ROOT / relative + + if not src_dir.exists(): + raise FileNotFoundError(f"Missing source directory: {src_dir}") + + print(f"[bsk-sdk] Copying {src_dir} -> {dest_dir}") + copy_tree(src_dir, dest_dir) + + print("[bsk-sdk] Header synchronization complete.") + + +if __name__ == "__main__": + main() diff --git a/tools/sync_runtime.py b/tools/sync_runtime.py new file mode 100644 index 0000000..e131516 --- /dev/null +++ b/tools/sync_runtime.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 + +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +""" +Synchronize a curated subset of Basilisk *runtime* sources into the SDK package. + +This copies selected .cpp files from the main Basilisk `src/` tree into: + + sdk/src/bsk_sdk/runtime_min/ + +and also auto-generates "flat include" compatibility shims into: + + sdk/src/bsk_sdk/include_compat/ + +so curated runtime_min translation units that do things like: + + #include "atmosphereBase.h" + +will still compile without patching upstream Basilisk sources. + +Notes: +- Shims are only generated for *quoted* includes: #include "Foo.h" +- System includes like are not shimmed. +- If a flat header name is ambiguous (multiple matches under include/Basilisk), + this script errors and you must add an explicit override in FLAT_HEADER_OVERRIDES. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _sync_paths import SDK_REPO_ROOT, resolve_basilisk_src_root + +SDK_RUNTIME_ROOT = SDK_REPO_ROOT / "src" / "bsk_sdk" / "runtime_min" +SDK_INCLUDE_ROOT = SDK_REPO_ROOT / "src" / "bsk_sdk" / "include" / "Basilisk" +SDK_COMPAT_INCLUDE_ROOT = SDK_REPO_ROOT / "src" / "bsk_sdk" / "include_compat" + +CPP_FILES: list[str] = ["simulation/environment/_GeneralModuleFiles/atmosphereBase.cpp"] + +# Flat-header resolution overrides +FLAT_HEADER_OVERRIDES: dict[str, str] = { + "atmosphereBase.h": "simulation/environment/_GeneralModuleFiles/atmosphereBase.h", + "linearAlgebra.h": "architecture/utilities/linearAlgebra.h", +} + +# Matches #include "Header.h" +INCLUDE_RE = re.compile(r'^\s*#\s*include\s*"([^"]+)"\s*$') + + +def copy_file(src: Path, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def _is_flat_header(h: str) -> bool: + return "/" not in h and "\\" not in h and h.endswith((".h", ".hpp")) + + +def _find_header_under_sdk(include_root: Path, header_name: str) -> Path | None: + """ + Find a header by basename under include_root. + """ + matches = list(include_root.rglob(header_name)) + if not matches: + return None + if len(matches) > 1: + raise RuntimeError( + f"Ambiguous header '{header_name}' found in multiple locations:\n" + + "\n".join(f" - {m}" for m in matches) + ) + return matches[0] + + +def _header_rel_to_basilisk(include_root: Path, header_path: Path) -> str: + return header_path.relative_to(include_root).as_posix() + + +def generate_compat_shims_for_runtime() -> None: + if not SDK_INCLUDE_ROOT.exists(): + raise RuntimeError( + f"SDK headers root does not exist: {SDK_INCLUDE_ROOT}\n" + "Did you run sync_headers.py first (or otherwise populate sdk/src/bsk_sdk/include/Basilisk)?" + ) + + SDK_COMPAT_INCLUDE_ROOT.mkdir(parents=True, exist_ok=True) + + runtime_cpp_files = sorted(SDK_RUNTIME_ROOT.glob("*.cpp")) + needed_flat_headers: set[str] = set() + + for cpp in runtime_cpp_files: + text = cpp.read_text(encoding="utf-8", errors="replace") + for line in text.splitlines(): + m = INCLUDE_RE.match(line) + if not m: + continue + hdr = m.group(1) + if _is_flat_header(hdr): + needed_flat_headers.add(hdr) + + for hdr in sorted(needed_flat_headers): + if hdr in FLAT_HEADER_OVERRIDES: + rel = FLAT_HEADER_OVERRIDES[hdr] + real = SDK_INCLUDE_ROOT / rel + if not real.exists(): + raise FileNotFoundError( + f"Override for '{hdr}' points to missing SDK header:\n" + f" {real}\n" + "Fix sync_headers.py DIRECTORIES or update FLAT_HEADER_OVERRIDES." + ) + else: + real = _find_header_under_sdk(SDK_INCLUDE_ROOT, hdr) + if real is None: + raise FileNotFoundError( + f"runtime_min needs '{hdr}' but it was not found under SDK headers:\n" + f" {SDK_INCLUDE_ROOT}\n" + "Fix sync_headers.py DIRECTORIES (or add an explicit override)." + ) + rel = _header_rel_to_basilisk(SDK_INCLUDE_ROOT, real) + + shim = SDK_COMPAT_INCLUDE_ROOT / hdr + shim.write_text(f'#pragma once\n#include "{rel}"\n', encoding="utf-8") + print(f"[bsk-sdk] compat shim: {shim} -> {rel}") + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Sync Basilisk runtime_min sources into bsk-sdk" + ) + ap.add_argument( + "--basilisk-root", + default=None, + help="Path to Basilisk repository root (or set BSK_BASILISK_ROOT).", + ) + args = ap.parse_args() + + src_root = resolve_basilisk_src_root(args.basilisk_root) + + if not src_root.exists(): + raise RuntimeError(f"Expected Basilisk src directory not found: {src_root}") + + SDK_RUNTIME_ROOT.mkdir(parents=True, exist_ok=True) + + for rel in CPP_FILES: + src = src_root / rel + if not src.exists(): + raise FileNotFoundError(f"Missing source file: {src}") + + dst = SDK_RUNTIME_ROOT / src.name + print(f"[bsk-sdk] Copying {src} -> {dst}") + copy_file(src, dst) + + generate_compat_shims_for_runtime() + print("[bsk-sdk] Runtime synchronization complete.") + + +if __name__ == "__main__": + main() diff --git a/tools/sync_sources.py b/tools/sync_sources.py new file mode 100644 index 0000000..71f1b93 --- /dev/null +++ b/tools/sync_sources.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + + +""" +Synchronize a small curated set of Basilisk source files into the SDK package. + +This complements sync_headers.py: + +- sync_headers.py vendors public headers into: + sdk/src/bsk_sdk/include/Basilisk/... + +- sync_sources.py vendors the minimal compiled implementation sources ("arch_min") + into: + sdk/src/bsk_sdk/arch_min/... + +These files are compiled by the bsk-sdk CMake project to produce bsk::arch_min, +so plugin authors do not have to compile them in every plugin. +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _sync_paths import SDK_REPO_ROOT, resolve_basilisk_src_root + +SDK_ARCH_MIN_ROOT = SDK_REPO_ROOT / "src" / "bsk_sdk" / "arch_min" + +ARCH_MIN_FILES: list[tuple[str, str]] = [ + ("architecture/_GeneralModuleFiles/sys_model.cpp", "sys_model.cpp"), + ("architecture/utilities/bskLogging.cpp", "bskLogging.cpp"), + ( + "architecture/utilities/moduleIdGenerator/moduleIdGenerator.cpp", + "moduleIdGenerator.cpp", + ), + ("architecture/utilities/linearAlgebra.c", "linearAlgebra.c"), +] + + +def copy_file(src: Path, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Sync Basilisk arch_min sources into bsk-sdk" + ) + ap.add_argument( + "--basilisk-root", + default=None, + help="Path to Basilisk repository root (or set BSK_BASILISK_ROOT).", + ) + args = ap.parse_args() + + src_root = resolve_basilisk_src_root(args.basilisk_root) + + if not src_root.exists(): + raise RuntimeError(f"Expected Basilisk src directory not found: {src_root}") + + SDK_ARCH_MIN_ROOT.mkdir(parents=True, exist_ok=True) + + copied: list[Path] = [] + for rel_src, out_name in ARCH_MIN_FILES: + src_file = src_root / rel_src + if not src_file.exists(): + raise FileNotFoundError(f"Missing source file: {src_file}") + + dest_file = SDK_ARCH_MIN_ROOT / out_name + print(f"[bsk-sdk] Copying {src_file} -> {dest_file}") + copy_file(src_file, dest_file) + copied.append(dest_file) + + print(f"[bsk-sdk] Source synchronization complete ({len(copied)} files).") + + +if __name__ == "__main__": + main() diff --git a/tools/sync_swig.py b/tools/sync_swig.py new file mode 100644 index 0000000..17cdda8 --- /dev/null +++ b/tools/sync_swig.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +# +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + + +""" +Synchronize a curated subset of Basilisk SWIG support files into the SDK package. + +Copies into: + + sdk/src/bsk_sdk/swig/... + +So plugin builds can depend solely on the installed `bsk-sdk` package. +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _sync_paths import SDK_REPO_ROOT, resolve_basilisk_root + +SDK_SWIG_ROOT = SDK_REPO_ROOT / "src" / "bsk_sdk" / "swig" + +SWIG_FILES: list[str] = [ + "src/architecture/_GeneralModuleFiles/swig_conly_data.i", + "src/architecture/_GeneralModuleFiles/swig_std_array.i", + "src/architecture/_GeneralModuleFiles/swig_eigen.i", + "src/architecture/_GeneralModuleFiles/sys_model.i", + "src/architecture/_GeneralModuleFiles/sys_model.h", + "src/architecture/utilities/bskException.swg", +] + + +def copy_file(src: Path, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Sync Basilisk SWIG support files into bsk-sdk" + ) + ap.add_argument( + "--basilisk-root", + default=None, + help="Path to Basilisk repository root (or set BSK_BASILISK_ROOT).", + ) + args = ap.parse_args() + + basilisk_root = resolve_basilisk_root(args.basilisk_root) + + SDK_SWIG_ROOT.mkdir(parents=True, exist_ok=True) + + copied: list[Path] = [] + for rel in SWIG_FILES: + src = basilisk_root / rel + if not src.exists(): + raise FileNotFoundError( + f"[bsk-sdk] Missing SWIG support file:\n {src}\n\n" + "Update sdk/tools/sync_swig.py (SWIG_FILES) to match repo layout." + ) + + rel_path = Path(rel) + + # Strip leading "src/" so SWIG includes like: + # %include "architecture/_GeneralModuleFiles/sys_model.i" + # work when you pass -I${BSK_SDK_SWIG_DIR} + if rel_path.parts and rel_path.parts[0] == "src": + rel_under_swig = Path(*rel_path.parts[1:]) + else: + rel_under_swig = rel_path.name + + dst = SDK_SWIG_ROOT / rel_under_swig + print(f"[bsk-sdk] Copying {src} -> {dst}") + copy_file(src, dst) + copied.append(dst) + + print(f"[bsk-sdk] SWIG synchronization complete ({len(copied)} files).") + + +if __name__ == "__main__": + main()