diff --git a/.github/scripts/build_optional_bsk_wheel.py b/.github/scripts/build_optional_bsk_wheel.py index cc4d72ace5c..cd0b8effe44 100644 --- a/.github/scripts/build_optional_bsk_wheel.py +++ b/.github/scripts/build_optional_bsk_wheel.py @@ -71,6 +71,8 @@ class OptionalComponent: description: str cmake_include: str support_files: tuple[tuple[str, re.Pattern[str]], ...] = () + binding_group: str | None = None + binding_loader: str | None = None COMPONENTS = { @@ -81,6 +83,8 @@ class OptionalComponent: description="Optional optical navigation extension modules for Basilisk.", # Basilisk treats OpenCV-backed modules as optical navigation modules. cmake_include="usingOpenCV", + binding_group="opNavNative", + binding_loader="_load_opnav", support_files=( ( "Windows dependency DLL", @@ -147,6 +151,7 @@ def module_files_for_component( component: OptionalComponent, *, include_libraries: bool = False, + combined: bool = False, ) -> tuple[tuple[str, re.Pattern[str]], ...]: module_files = [] for module_dir in discover_cmake_include_modules(component.cmake_include): @@ -159,10 +164,11 @@ def module_files_for_component( ), ( f"_{target}", - re.compile(rf"{re.escape(package_root)}/_{re.escape(target)}\.(so|pyd)"), + re.compile(rf"{re.escape(package_root)}/_{re.escape(target)}\." + + ("py" if combined else "(so|pyd)")), ), ]) - if include_libraries: + if include_libraries and not combined: module_files.append(( f"{target}.lib", re.compile(rf"{re.escape(package_root)}/{re.escape(target)}\.lib"), @@ -171,12 +177,33 @@ def module_files_for_component( return tuple(module_files) -def expected_files_for_component(component: OptionalComponent) -> tuple[tuple[str, re.Pattern[str]], ...]: - return module_files_for_component(component) +def grouped_files_for_component( + component: OptionalComponent, *, include_libraries: bool = False, +) -> tuple[tuple[str, re.Pattern[str]], ...]: + """Describe the optional container and its loader without owning core files.""" + if component.binding_group is None: + return () + group = re.escape(component.binding_group) + files = [(component.binding_group, re.compile(rf"Basilisk/_{group}\.(so|pyd)")), + (component.binding_loader, re.compile(rf"Basilisk/{re.escape(component.binding_loader)}\.py"))] + if include_libraries: + files.append((component.binding_group + ".lib", re.compile(rf"Basilisk/_?{group}\.lib"))) + return tuple(files) + + +def expected_files_for_component( + component: OptionalComponent, *, combined: bool = False, +) -> tuple[tuple[str, re.Pattern[str]], ...]: + return module_files_for_component(component, combined=combined) + ( + grouped_files_for_component(component) if combined else ()) -def allowed_files_for_component(component: OptionalComponent) -> tuple[tuple[str, re.Pattern[str]], ...]: - allowed_files = list(module_files_for_component(component, include_libraries=True)) +def allowed_files_for_component( + component: OptionalComponent, *, combined: bool = False, +) -> tuple[tuple[str, re.Pattern[str]], ...]: + allowed_files = list(module_files_for_component(component, include_libraries=True, combined=combined)) + if combined: + allowed_files.extend(grouped_files_for_component(component, include_libraries=True)) allowed_files.extend(component.support_files) return tuple(allowed_files) @@ -184,7 +211,9 @@ def allowed_files_for_component(component: OptionalComponent) -> tuple[tuple[str def optional_module_files_for_component( component: OptionalComponent, ) -> tuple[tuple[str, re.Pattern[str]], ...]: - return module_files_for_component(component, include_libraries=True) + return (module_files_for_component(component, include_libraries=True) + + module_files_for_component(component, combined=True) + + grouped_files_for_component(component, include_libraries=True)) def find_dist_info_file(names: list[str], suffix: str) -> str: @@ -654,8 +683,10 @@ def validate_delta( component_payload: set[str], component: OptionalComponent, ) -> list[str]: - expected_files = expected_files_for_component(component) - allowed_files = allowed_files_for_component(component) + group_files = grouped_files_for_component(component) + combined = bool(group_files) and any(group_files[0][1].fullmatch(name) for name in component_payload) + expected_files = expected_files_for_component(component, combined=combined) + allowed_files = allowed_files_for_component(component, combined=combined) optional_module_files = optional_module_files_for_component(component) optional_in_base = sorted( name diff --git a/.github/scripts/test_incremental_build.py b/.github/scripts/test_incremental_build.py index 994dab9191b..895f0950093 100644 --- a/.github/scripts/test_incremental_build.py +++ b/.github/scripts/test_incremental_build.py @@ -25,6 +25,7 @@ from __future__ import annotations import argparse +import json import os import shutil import subprocess @@ -161,33 +162,52 @@ def run_build( return result.stdout -def assert_package_configuration(source_dir: Path, build_dir: Path) -> None: - """Check header-only libraries and shared Custom.cmake discovery.""" +def configured_target_names(source_dir: Path, build_dir: Path) -> set[str]: + """List generated targets using the generator-independent CMake File API.""" - help_command = [ - cmake_executable(), - "--build", - str(build_dir), - "--target", - "help", - ] - help_result = subprocess.run( - help_command, + # Visual Studio and Xcode do not provide the Make/Ninja ``help`` target. + api_dir = build_dir / ".cmake/api/v1" + client = "client-bsk-incremental-test" + query_dir = api_dir / "query" / client + query_dir.mkdir(parents=True, exist_ok=True) + (query_dir / "codemodel-v2").touch() + result = subprocess.run( + [cmake_executable(), "-S", str(source_dir), "-B", str(build_dir)], check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) - if help_result.returncode: + if result.returncode: + raise IncrementalBuildError(f"CMake target query failed:\n{result.stdout}") + + reply_dir = api_dir / "reply" + indexes = sorted(reply_dir.glob("index-*.json")) + if not indexes: + raise IncrementalBuildError("CMake did not generate a File API reply index.") + try: + index = json.loads(indexes[-1].read_text(encoding="utf-8")) + reply = index["reply"][client]["codemodel-v2"] + codemodel = json.loads((reply_dir / reply["jsonFile"]).read_text(encoding="utf-8")) + return { + target["name"] + for configuration in codemodel["configurations"] + for target in configuration["targets"] + } + except (KeyError, ValueError, OSError) as error: raise IncrementalBuildError( - f"CMake target listing failed with exit status {help_result.returncode}.\n" - f"{help_result.stdout}" - ) + f"Could not read the CMake target query reply: {error}" + ) from error + +def assert_package_configuration(source_dir: Path, build_dir: Path) -> None: + """Check header-only libraries and shared Custom.cmake discovery.""" + + target_names = configured_target_names(source_dir, build_dir) unexpected_libraries = [ library for library in ("communicationLib", "vizardLib") - if library in help_result.stdout + if library in target_names ] if unexpected_libraries: raise IncrementalBuildError( @@ -201,8 +221,9 @@ def assert_package_configuration(source_dir: Path, build_dir: Path) -> None: with tempfile.TemporaryDirectory(prefix="bsk-cmake-package-") as temporary_dir: custom_file_command = [ cmake_executable(), - f"-DBSK_SOURCE_DIR={source_dir}", - f"-DTEST_ROOT={temporary_dir}", + # CMake compares these values with its forward-slash glob results. + f"-DBSK_SOURCE_DIR={source_dir.as_posix()}", + f"-DTEST_ROOT={Path(temporary_dir).as_posix()}", "-P", str(test_script), ] @@ -329,6 +350,123 @@ def assert_no_compilation( ) +def assert_single_message_rebuild( + build_dir: Path, + targets: tuple[WrapperTarget, ...], + parallel: int, + configuration: Optional[str], +) -> None: + """Require a message interface change to rebuild only its wrapper object.""" + + message_name = "AttRefMsgPayload" + interface = build_dir / "autoSource" / f"{message_name}.i" + messaging_dir = build_dir / "Basilisk/architecture/messaging" + libraries = [ + path + for path in messaging_dir.glob("_messagingNative.*") + if path.suffix in {".so", ".pyd"} + ] + if len(libraries) != 1: + raise IncrementalBuildError( + f"Expected one combined messaging library, found {libraries}." + ) + library = libraries[0] + require_files((interface, library)) + before = compiled_artifacts(build_dir) + library_mtime = library.stat().st_mtime_ns + with temporarily_touch(interface, (library,)): + run_build(build_dir, targets, parallel, configuration) + after = compiled_artifacts(build_dir) + changed = [ + path + for path in before.keys() | after.keys() + if before.get(path) != after.get(path) + ] + if len(changed) != 1 or changed[0].name not in { + f"{message_name}PYTHON_wrap.cxx.o", + f"{message_name}PYTHON_wrap.cxx.obj", + f"{message_name}PYTHON_wrap.obj", + }: + raise IncrementalBuildError( + "A single message interface change rebuilt unexpected objects: " + + ", ".join(str(path) for path in changed) + ) + if library.stat().st_mtime_ns <= library_mtime: + raise IncrementalBuildError("The combined messaging library was not relinked.") + print("PASS: one message interface rebuilt only its object and relinked the library.") + + +def assert_single_fsw_rebuild( + source_dir: Path, + build_dir: Path, + targets: tuple[WrapperTarget, ...], + parallel: int, + configuration: Optional[str], +) -> None: + """Require one FSW edit to rebuild one object and relink the combined library.""" + + libraries = [ + path + for path in (build_dir / "Basilisk/fswAlgorithms").glob("_fswCoreNative.*") + if path.suffix in {".so", ".pyd"} + ] + if not libraries: + return + if len(libraries) != 1: + raise IncrementalBuildError(f"Expected one combined FSW library, found {libraries}.") + library = libraries[0] + for suffix, object_names in ( + ("c", {"mrpFeedback.c.o", "mrpFeedback.c.obj", "mrpFeedback.obj"}), + ("i", {"mrpFeedbackPYTHON_wrap.cxx.o", "mrpFeedbackPYTHON_wrap.cxx.obj", + "mrpFeedbackPYTHON_wrap.obj"}), + ): + source = source_dir / f"fswAlgorithms/attControl/mrpFeedback/mrpFeedback.{suffix}" + require_files((source, library)) + before = compiled_artifacts(build_dir) + library_mtime = library.stat().st_mtime_ns + with temporarily_touch(source, (library,)): + run_build(build_dir, targets, parallel, configuration) + after = compiled_artifacts(build_dir) + changed = [path for path in before.keys() | after.keys() + if before.get(path) != after.get(path)] + if len(changed) != 1 or changed[0].name not in object_names: + raise IncrementalBuildError( + f"A single FSW .{suffix} edit rebuilt unexpected objects: " + + ", ".join(str(path) for path in changed) + ) + if library.stat().st_mtime_ns <= library_mtime: + raise IncrementalBuildError("The combined FSW library was not relinked.") + print(f"PASS: one FSW .{suffix} edit rebuilt only its object and relinked the library.") + + +def assert_message_initializer_regenerated( + source_dir: Path, + build_dir: Path, + targets: tuple[WrapperTarget, ...], + parallel: int, + configuration: Optional[str], +) -> None: + """Check that reconfiguration cannot mask a missing generated initializer.""" + + initializer = build_dir / "Basilisk/architecture/messaging/__init__.py" + initializer.unlink() + result = subprocess.run( + [cmake_executable(), "-S", str(source_dir), "-B", str(build_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if result.returncode: + raise IncrementalBuildError(f"CMake reconfiguration failed:\n{result.stdout}") + run_build(build_dir, targets, parallel, configuration) + if not initializer.read_text(encoding="utf-8").strip(): + raise IncrementalBuildError( + "CMake left an empty messaging initializer after reconfiguration." + ) + print("PASS: reconfiguring a cleaned package regenerated its messaging initializer.") + + def run_regression_test(args: argparse.Namespace) -> None: """Run positive, negative, and no-change incremental-build checks.""" @@ -358,6 +496,12 @@ def run_regression_test(args: argparse.Namespace) -> None: assert_wrappers_unchanged(before_cpp, after_cpp) print("PASS: an unrelated .cpp did not regenerate any wrapper.") + assert_single_message_rebuild(build_dir, targets, args.parallel, args.config) + assert_single_fsw_rebuild(source_dir, build_dir, targets, args.parallel, args.config) + assert_message_initializer_regenerated( + source_dir, build_dir, targets, args.parallel, args.config + ) + before_unchanged_wrappers = modification_times(targets) before_unchanged = compiled_artifacts(build_dir) unchanged_output = run_build(build_dir, targets, args.parallel, args.config) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b906cf0d857..2cc325018f0 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -75,6 +75,11 @@ jobs: run: | pip install pytest-error-for-skips pytest-timeout pytest -n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread --durations=25 -v + - name: Build integration tests + working-directory: src + run: >- + pytest -n 2 -m buildIntegration --error-for-skips --timeout=300 --timeout-method=thread --durations=10 -v + tests/test_fswBindingLayout.py tests/test_incremental_build_checks.py tests/test_cmake_message_cleanup.py - name: CTest if: ${{ always() && hashFiles('dist3/CTestTestfile.cmake') != '' }} working-directory: dist3 @@ -107,6 +112,12 @@ jobs: pip install pytest-error-for-skips pytest-timeout pytest -n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread --durations=25 -v if(($LastExitCode -ne 0) -and ($LastExitCode -ne 5)) {exit 1} + - name: Build integration tests + shell: pwsh + working-directory: src + run: | + pytest -n 2 -m buildIntegration --error-for-skips --timeout=300 --timeout-method=thread --durations=10 -v tests/test_fswBindingLayout.py tests/test_incremental_build_checks.py tests/test_cmake_message_cleanup.py + if ($LastExitCode -ne 0) { exit $LastExitCode } - name: CTest if: ${{ always() && hashFiles('dist3/CTestTestfile.cmake') != '' }} shell: pwsh @@ -172,6 +183,11 @@ jobs: run: | pip install pytest-error-for-skips pytest-timeout pytest ${{ matrix.pytest_flags }} + - name: Build integration tests + working-directory: src + run: >- + pytest -n 2 -m buildIntegration --error-for-skips --timeout=300 --timeout-method=thread --durations=10 -v + tests/test_fswBindingLayout.py tests/test_incremental_build_checks.py tests/test_cmake_message_cleanup.py - name: CTest if: ${{ always() && hashFiles('dist3/CTestTestfile.cmake') != '' }} working-directory: dist3 diff --git a/benchmarks/startup/_collection_profile.py b/benchmarks/startup/_collection_profile.py new file mode 100644 index 00000000000..b5232346a00 --- /dev/null +++ b/benchmarks/startup/_collection_profile.py @@ -0,0 +1,70 @@ +# 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. + +"""Explicit pytest plugin for collection-only startup measurements.""" + +import _imp +import json +import os +from pathlib import Path +import time + +import pytest + + +STARTED = float(os.environ["BSK_STARTUP_STARTED"]) +OUTPUT = Path(os.environ["BSK_STARTUP_OUTPUT"]) +WORKER = os.environ.get("PYTEST_XDIST_WORKER", "controller") +NATIVE_CALLS = [] +READY_SECONDS = None +SELECTED_ITEMS = None +ORIGINAL_CREATE_DYNAMIC = _imp.create_dynamic + + +def create_dynamic(spec, *args, **kwargs): + """Time native initialization without changing the extension loader's behavior.""" + started = time.perf_counter() + try: + return ORIGINAL_CREATE_DYNAMIC(spec, *args, **kwargs) + finally: + NATIVE_CALLS.append({"name": spec.name, "file": spec.origin, + "seconds": time.perf_counter() - started}) + + +_imp.create_dynamic = create_dynamic + + +@pytest.hookimpl(trylast=True) +def pytest_collection_modifyitems(items): + """Record selected collection readiness and prevent test execution.""" + global READY_SECONDS, SELECTED_ITEMS + READY_SECONDS = time.time() - STARTED + SELECTED_ITEMS = len(items) + items.clear() + + +def pytest_sessionfinish(exitstatus): + """Write one profile per process, preserving collection failures in the exit code.""" + data = {"worker": WORKER, "ready_seconds": READY_SECONDS, + "selected_items": SELECTED_ITEMS, "exit_code": int(exitstatus), "native": NATIVE_CALLS} + (OUTPUT / f"worker-{WORKER}.json").write_text( + json.dumps(data, indent=2) + "\n", encoding="utf-8" + ) + + +def pytest_unconfigure(): + """Restore the native loader when the explicit benchmark plugin shuts down.""" + _imp.create_dynamic = ORIGINAL_CREATE_DYNAMIC diff --git a/benchmarks/startup/benchmark_fsw_bindings.py b/benchmarks/startup/benchmark_fsw_bindings.py new file mode 100644 index 00000000000..e334148ff7e --- /dev/null +++ b/benchmarks/startup/benchmark_fsw_bindings.py @@ -0,0 +1,374 @@ +# 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. + +"""Measure native binding groups in an existing Ninja build. + +The full benchmark removes owned generated outputs and touches one source at a +time. It restores source timestamps and rebuilds removed outputs before exiting. +``--smoke`` only imports one existing binding and does not modify the build. +""" + +import argparse +import json +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +import tempfile +import time + + +REPOSITORY = Path(__file__).resolve().parents[2] +MTIME_MARGIN_SECONDS = 1.1 # [s] +NATIVE_SUFFIXES = {".so", ".pyd", ".dll", ".dylib"} +GROUPS = { + "fsw": ("fswCoreNative", "fswCoreBindings.txt", "fswAlgorithms", "FswObjects", + "fswAlgorithms/attControl/mrpFeedback/mrpFeedback.c"), + "simulation": ("simulationCoreNative", "simulationCoreNativeBindings.txt", + "simulation", "GroupedObjects", "simulation/dynamics/spacecraft/spacecraft.cpp"), + "mujoco": ("mujocoNative", "mujocoNativeBindings.txt", "simulation", "GroupedObjects", + "simulation/mujocoDynamics/thrOnTimeToForce/thrOnTimeToForce.cpp"), + "opnav": ("opNavNative", "opNavNativeBindings.txt", "", "GroupedObjects", + "simulation/sensors/camera/camera.cpp"), +} +IMPORT_PROBE = """ +import importlib +import json +from pathlib import Path +import sys +import time +sys.path.insert(0, sys.argv[1]) +import Basilisk +assert Path(Basilisk.__file__).resolve().is_relative_to(Path(sys.argv[1]).resolve()) +started = time.perf_counter() +for name in json.loads(sys.argv[2]): + importlib.import_module('Basilisk.' + name) +print(json.dumps({'seconds': time.perf_counter() - started, + 'python_version': sys.version})) +""" + + +def read_cache(build_dir): + """Read CMake cache values without depending on the caller's environment.""" + values = {} + for line in (build_dir / "CMakeCache.txt").read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition("=") + if separator and ":" in key and not key.startswith(("//", "#")): + values[key.split(":", 1)[0]] = value + return values + + +def remove_outputs(build_dir, paths): + """Remove an explicit list of generated files confined to the build directory.""" + paths = list(paths) + for path in paths: + if not path.resolve().is_relative_to(build_dir.resolve()): + raise ValueError(f"Refusing to remove an output outside the build directory: {path}") + for path in paths: + path.unlink(missing_ok=True) + + +def linker_outputs(build_dir, target_listing): + """Select project native outputs from Ninja linker rules, excluding import libraries.""" + outputs = set() + for line in target_listing.splitlines(): + match = re.fullmatch( + r"(Basilisk/[^:]+): (?:C|CXX)_(?:MODULE|SHARED)_LIBRARY_LINKER.*", line + ) + if match: + path = build_dir / match[1] + if path.suffix in NATIVE_SUFFIXES: + outputs.add(path) + return sorted(outputs) + + +class Benchmark: + """Manage sequential timing runs and restoration of an existing build.""" + + def __init__(self, args): + self.args = args + self.group = getattr(args, "group", "fsw") + self.native_target, self.manifest_name, package, self.object_suffix, source = GROUPS[self.group] + self.representative_source = Path(source) + self.representative_module = source.split("/")[0] + "." + Path(source).stem + self.build_dir = args.build_dir.resolve() + self.cache = read_cache(self.build_dir) + self.source_dir = Path(self.cache["CMAKE_HOME_DIRECTORY"]).resolve() + if self.source_dir != REPOSITORY / "src": + raise ValueError("The build directory must belong to this Basilisk checkout.") + if not args.smoke and self.cache["CMAKE_GENERATOR"] != "Ninja": + raise ValueError("Build measurements require the single-configuration Ninja generator.") + self.python = self.cache.get("Python3_EXECUTABLE", self.cache.get("_Python3_EXECUTABLE", sys.executable)) + self.cmake = self.cache["CMAKE_COMMAND"] + self.restore_all_targets = False + self.package_dir = self.build_dir / "Basilisk" / package + self.names = [] + self.proxies = {} + self.mode = "combined" + self.results = { + "schema_version": 2, + "group": self.group, + "platform": platform.platform(), + "machine": platform.machine(), + "parallel": args.parallel, + "layout": self.mode, + "cache_settings": {key: self.cache.get(key) for key in ( + "CMAKE_GENERATOR", "CMAKE_BUILD_TYPE", "PY_LIMITED_API", "BSK_STRICT_WARNINGS", + "BUILD_OPNAV", "BUILD_MUJOCO", "BUILD_VIZINTERFACE", "BUILD_RUST_MODULES", + )}, + "measurements": [], + } + self.output = None + if not args.smoke: + if args.output: + self.output = args.output.resolve() + self.output.mkdir(parents=True, exist_ok=False) + else: + parent = self.build_dir / "benchmarks" + parent.mkdir(exist_ok=True) + self.output = Path(tempfile.mkdtemp(prefix=f"{self.group}-startup-", dir=parent)) + + def record(self, label, **values): + """Append a measurement and save partial results after each completed step.""" + row = {"layout": self.mode, "label": label, **values} + self.results["measurements"].append(row) + print(json.dumps({key: value for key, value in row.items() if key != "profiles"}), flush=True) + self.save() + return row + + def save(self): + """Write results using portable JSON, including partial runs on failure.""" + if self.output is not None: + (self.output / "results.json").write_text( + json.dumps(self.results, indent=2) + "\n", encoding="utf-8" + ) + + def run_logged(self, label, command, **kwargs): + """Run a command with a separate log and expose failures to the caller.""" + log_path = self.output / f"{self.mode}-{label}.log" + with log_path.open("w", encoding="utf-8") as log: + result = subprocess.run(command, cwd=REPOSITORY, stdout=log, + stderr=subprocess.STDOUT, **kwargs) + if result.returncode: + raise RuntimeError(f"Command exited {result.returncode}; see {log_path}") + return log_path + + def configure(self): + """Refresh group manifests without changing configured build options.""" + self.run_logged("configure", [self.cmake, "-S", str(self.source_dir), + "-B", str(self.build_dir)]) + + def build(self, label, all_targets=False): + """Time a build, retaining its log and compilation, SWIG, and link counts.""" + command = [self.cmake, "--build", str(self.build_dir), + "--parallel", str(self.args.parallel)] + if not all_targets: + command.extend(["--target", self.native_target]) + started = time.perf_counter() + log = self.run_logged(label, command) + elapsed = time.perf_counter() - started + content = log.read_text(encoding="utf-8") + return self.record(label, seconds=elapsed, + compiles=content.count("Building C object") + + content.count("Building CXX object"), + swig=content.count("Swig compile"), links=content.count("Linking ")) + + def native_outputs(self): + """Return only the native library owned by the selected group.""" + suffix = ".pyd" if os.name == "nt" else ".so" + return [self.package_dir / f"_{self.native_target}{suffix}"] + + def public_path(self, name): + """Locate a public proxy, including groups spanning multiple packages.""" + if "." not in name: + name = "fswAlgorithms." + name + return self.build_dir.joinpath("Basilisk", *name.split(".")) + + def objects(self): + """Find active Ninja object outputs, ignoring stale files from older builds.""" + listing = subprocess.check_output( + [self.cache["CMAKE_MAKE_PROGRAM"], "-C", str(self.build_dir), "-t", "targets", "all"], + text=True, + ) + targets = {name.split(".")[-1] + self.object_suffix + for name in self.names} + objects = [] + for line in listing.splitlines(): + match = re.fullmatch(r"(CMakeFiles/([^/]+)\.dir/[^:]+): (?:C|CXX)_COMPILER.*", line) + if match and match[2] in targets: + objects.append(self.build_dir / match[1]) + return objects + + def import_pair(self, label, names): + """Measure consecutive fresh processes, isolating group imports after Basilisk.""" + names = [name if "." in name else "fswAlgorithms." + name for name in names] + names = [self.proxies.get(name, name) for name in names] + for temperature in ("cold", "warm"): + result = subprocess.run( + [self.python, "-c", IMPORT_PROBE, str(self.build_dir), json.dumps(names)], + cwd=REPOSITORY, capture_output=True, text=True, check=True, + ) + self.record(f"{label}-{temperature}", **json.loads(result.stdout)) + + def relink(self, label, all_targets=False): + """Reset generated native files and require a build without C/C++ or SWIG work.""" + paths = self.native_outputs() + if all_targets: + listing = subprocess.check_output( + [self.cache["CMAKE_MAKE_PROGRAM"], "-C", str(self.build_dir), "-t", "targets", "all"], + text=True, + ) + paths = linker_outputs(self.build_dir, listing) + if not paths: + raise RuntimeError("No project native linker outputs were found.") + # A failure or interruption after deletion must also recover native + # libraries outside FSW, including simulation and optional modules. + self.restore_all_targets = True + remove_outputs(self.build_dir, paths) + row = self.build(label, all_targets) + if all_targets: + self.restore_all_targets = False + if row["compiles"] or row["swig"]: + raise RuntimeError(f"Unexpected compilation during a link-only measurement: {row}") + + def incremental(self, suffix, trial): + """Touch one representative input and restore its timestamps even after failure.""" + representative = getattr(self, "representative_source", Path("fswAlgorithms/attControl/mrpFeedback/mrpFeedback.c")) + source = self.source_dir / representative.with_suffix("." + suffix) + original = source.stat() + newest = max(path.stat().st_mtime for path in self.native_outputs()) + time.sleep(max(0.0, newest - time.time()) + MTIME_MARGIN_SECONDS) + try: + source.touch() + row = self.build(f"change-{suffix}-{trial}") + if row["compiles"] != 1 or row["swig"] != int(suffix == "i") or row["links"] != 1: + raise RuntimeError(f"Unexpected work after a single-module edit: {row}") + finally: + os.utime(source, ns=(original.st_atime_ns, original.st_mtime_ns)) + + def collection(self, temperature): + """Collect the CI-selected suite in workers without executing its tests.""" + output = self.output / f"{self.mode}-pytest-{temperature}" + output.mkdir() + env = os.environ.copy() + python_paths = [str(self.build_dir), str(Path(__file__).parent), env.get("PYTHONPATH")] + env.update( + MPLBACKEND="Agg", BSK_STARTUP_OUTPUT=str(output), + BSK_STARTUP_STARTED=str(time.time()), + PYTHONPATH=os.pathsep.join(filter(None, python_paths)), + ) + command = [self.python, "-m", "pytest", "-n", str(self.args.pytest_workers), + "-m", "not ciSkip", "-rs", "-q", "-p", "_collection_profile", str(self.source_dir)] + with (output / "pytest.log").open("w", encoding="utf-8") as log: + result = subprocess.run(command, cwd=REPOSITORY, env=env, + stdout=log, stderr=subprocess.STDOUT) + profiles = [json.loads(path.read_text(encoding="utf-8")) + for path in output.glob("worker-*.json")] + ready = [profile["ready_seconds"] for profile in profiles + if profile["ready_seconds"] is not None] + expected_profiles = self.args.pytest_workers or 1 + if result.returncode not in (0, 5) or len(ready) != expected_profiles: + raise RuntimeError(f"Collection failed; see {output / 'pytest.log'}") + self.record(f"pytest-{temperature}", seconds=max(ready), + workers=self.args.pytest_workers, profiles=profiles) + + def measure_group(self): + """Measure compilation, loading, incremental edits, and optional collection.""" + self.build("settle") + expected_objects = len(self.objects()) + for trial in range(self.args.trials): + outputs = self.objects() + self.native_outputs() + outputs.extend(self.public_path(self.proxies.get(name, name)).with_suffix(".py") + for name in self.names) + outputs.extend(self.public_path(name).with_name(name.split(".")[-1] + "PYTHON_wrap.cxx") + for name in self.names) + remove_outputs(self.build_dir, outputs) + row = self.build(f"rebuild-{trial}") + if row["compiles"] != expected_objects or row["swig"] != len(self.names): + raise RuntimeError(f"Unexpected work during a full binding-group rebuild: {row}") + self.import_pair(f"all-{self.group}-{trial}", self.names) + self.relink("single-import-relink") + self.import_pair(f"one-{self.group}", [self.representative_module]) + for trial in range(self.args.trials): + self.relink(f"relink-{trial}") + for suffix in (self.representative_source.suffix[1:], "i"): + self.incremental(suffix, trial) + row = self.build("no-change") + if row["compiles"] or row["swig"] or row["links"]: + raise RuntimeError(f"An unchanged build performed unexpected work: {row}") + self.record("native-size", bytes=sum(path.stat().st_size for path in self.native_outputs())) + if self.args.pytest_workers is not None: + self.build("prepare-collection", all_targets=True) + self.relink("all-native-relink", all_targets=True) + self.collection("cold") + self.collection("warm") + + def run(self): + """Measure the selected group and recover removed outputs in all cases.""" + if self.args.smoke: + result = subprocess.run( + [self.python, "-B", "-c", IMPORT_PROBE, str(self.build_dir), json.dumps([self.representative_module])], + capture_output=True, text=True, check=True, + ) + print(result.stdout.strip()) + return + try: + self.configure() + manifest = self.build_dir / "autoSource" / self.manifest_name + self.names = sorted(manifest.read_text(encoding="utf-8").split()) + proxy_manifest = self.build_dir / "autoSource" / f"{self.native_target}Proxies.txt" + if self.group != "fsw": + self.proxies = dict(line.split("=", 1) for line in proxy_manifest.read_text().splitlines() if line) + representative = self.representative_module if self.group != "fsw" else "mrpFeedback" + if representative not in self.names or any( + re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*(?:\.[A-Za-z_][A-Za-z_0-9]*)?", name) is None for name in self.names): + raise ValueError("The binding manifest is empty or invalid; enable the selected group's build feature.") + self.results["modules"] = self.names + self.measure_group() + except BaseException as error: + self.results["error"] = str(error) + raise + finally: + self.results["restored_build"] = False + try: + if self.names or self.restore_all_targets: + self.build("restore", all_targets=self.restore_all_targets) + self.results["restored_build"] = True + finally: + self.save() + print(f"Results: {self.output / 'results.json'}", flush=True) + + +def main(): + """Parse portable paths and benchmark controls, then measure the selected group.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--group", choices=GROUPS, default="fsw") + parser.add_argument("--build-dir", type=Path, default=REPOSITORY / "dist3") + parser.add_argument("--output", type=Path, help="New output directory; defaults under the build tree") + parser.add_argument("--trials", type=int, default=3) + parser.add_argument("--parallel", type=int, default=min(os.cpu_count() or 1, 12)) + parser.add_argument("--pytest-workers", type=int, help="Also measure CI collection with this worker count") + parser.add_argument("--smoke", action="store_true", help="Only import one existing binding; do not rebuild") + args = parser.parse_args() + if args.trials < 1 or args.parallel < 1 or (args.pytest_workers is not None and args.pytest_workers < 0): + parser.error("Trials and parallel jobs must be positive; pytest workers must be nonnegative.") + Benchmark(args).run() + + +if __name__ == "__main__": + main() diff --git a/benchmarks/startup/results/macos-arm64-2026-10-01.json b/benchmarks/startup/results/macos-arm64-2026-10-01.json new file mode 100644 index 00000000000..8c451f1e327 --- /dev/null +++ b/benchmarks/startup/results/macos-arm64-2026-10-01.json @@ -0,0 +1,782 @@ +{ + "schema_version": 1, + "date": "2026-10-01", + "base_commit": "b355572568fd1d53e42f8d1da51659ebf63eb66c", + "source_state": "Core FSW binding prototype applied to base commit", + "environment": { + "os": "macOS 27.2", + "architecture": "arm64", + "python": "3.14.6", + "cmake": "4.3.2", + "generator": "Ninja", + "compiler": "AppleClang 21", + "configuration": "Release", + "strict_warnings": true, + "optional_features": [ + "OpenCV", + "MuJoCo", + "visualization", + "Rust" + ], + "build_parallelism": 12, + "pytest_workers": 16 + }, + "units": { + "time": "seconds", + "native_size": "bytes" + }, + "cold_definition": "Freshly built or relinked native outputs; no reboot or disk-cache flush", + "modules": [ + "attRefCorrection", + "attTrackingError", + "celestialTwoBodyPoint", + "chebyPosEphem", + "constrainedAttitudeManeuver", + "cssComm", + "cssWlsEst", + "dipoleMapping", + "dvAccumulation", + "dvAttEffect", + "dvExecuteGuidance", + "dvGuidance", + "ephemDifference", + "ephemNavConverter", + "etSphericalControl", + "eulerRotation", + "faultDetection", + "forceTorqueThrForceMapping", + "formationBarycenter", + "headingSuKF", + "hillFrameRelativeControl", + "hillPoint", + "hillStateConverter", + "hillToAttRef", + "hingedJointArrayMotor", + "hingedRigidBodyPIDMotor", + "horizonOpNav", + "imuComm", + "inertial3D", + "inertial3DSpin", + "inertialCartFeedback", + "inertialUKF", + "jointMotionCompensator", + "lambertPlanner", + "lambertSecondDV", + "lambertSolver", + "lambertSurfaceRelativeVelocity", + "lambertValidator", + "locationPointing", + "lowPassFilterTorqueCommand", + "meanOEFeedback", + "mrpFeedback", + "mrpPD", + "mrpRotation", + "mrpSteering", + "mtbFeedforward", + "mtbMomentumManagement", + "mtbMomentumManagementSimple", + "navAggregate", + "oeStateEphem", + "okeefeEKF", + "oneAxisSolarArrayPoint", + "opNavPoint", + "orbElemOffset", + "pixelLineBiasUKF", + "pixelLineConverter", + "prescribedRot2DOF", + "prvSteering", + "rasterManager", + "rateMsgConverter", + "rateServoFullNonlinear", + "relativeODuKF", + "rwConfigData", + "rwMotorTorque", + "rwMotorVoltage", + "rwNullSpace", + "scanningInstrumentController", + "simpleDeadband", + "simpleInstrumentController", + "smallBodyNavEKF", + "smallBodyNavUKF", + "smallBodyWaypointFeedback", + "solarArrayReference", + "spacecraftPointing", + "spacecraftReconfig", + "stComm", + "sunSafeACS", + "sunSafePoint", + "sunlineEKF", + "sunlineEphem", + "sunlineSEKF", + "sunlineSuKF", + "sunlineUKF", + "tamComm", + "thrFiringRemainder", + "thrFiringSchmitt", + "thrForceMapping", + "thrJointCompensation", + "thrMomentumDumping", + "thrMomentumManagement", + "thrustCMEstimation", + "thrustRWDesat", + "thrusterPlatformReference", + "thrusterPlatformState", + "torque2Dipole", + "torqueScheduler", + "vehicleConfigData", + "velocityPoint", + "vscmgGimbalRateServo", + "vscmgVelocitySteering", + "waypointReference" + ], + "full_core": { + "separate": { + "mode": "separate", + "modules": 101, + "parallel": 12, + "measurements": [ + { + "label": "settle", + "seconds": 2.0007583330152556, + "cpu_seconds": 1.1816580000000005, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "label": "full-core-0", + "seconds": 30.837668958003633, + "cpu_seconds": 306.188944, + "compiles": 201, + "swig": 101, + "links": 101 + }, + { + "label": "all-fsw-0-0", + "seconds": 12.544168874970637 + }, + { + "label": "all-fsw-0-1", + "seconds": 0.2604561250191182 + }, + { + "label": "full-core-1", + "seconds": 30.669575957930647, + "cpu_seconds": 300.381232, + "compiles": 201, + "swig": 101, + "links": 101 + }, + { + "label": "all-fsw-1-0", + "seconds": 10.439232041942887 + }, + { + "label": "all-fsw-1-1", + "seconds": 0.28895808302331716 + }, + { + "label": "full-core-2", + "seconds": 29.140211957972497, + "cpu_seconds": 285.8715030000001, + "compiles": 201, + "swig": 101, + "links": 101 + }, + { + "label": "all-fsw-2-0", + "seconds": 13.411425124970265 + }, + { + "label": "all-fsw-2-1", + "seconds": 0.24899545905645937 + }, + { + "label": "no-change", + "seconds": 2.5526023749262094, + "cpu_seconds": 1.3282330000000826, + "compiles": 0, + "swig": 0, + "links": 0 + } + ], + "native_bytes": 34237888 + }, + "combined": { + "mode": "combined", + "modules": 101, + "parallel": 12, + "measurements": [ + { + "label": "settle", + "seconds": 24.835138999973424, + "cpu_seconds": 233.23526299999992, + "compiles": 101, + "swig": 101, + "links": 1 + }, + { + "label": "full-core-0", + "seconds": 28.874506167019717, + "cpu_seconds": 271.83278999999993, + "compiles": 201, + "swig": 101, + "links": 1 + }, + { + "label": "all-fsw-0-0", + "seconds": 0.95330183301121 + }, + { + "label": "all-fsw-0-1", + "seconds": 0.09316624992061406 + }, + { + "label": "full-core-1", + "seconds": 29.383486000006087, + "cpu_seconds": 274.4362510000001, + "compiles": 201, + "swig": 101, + "links": 1 + }, + { + "label": "all-fsw-1-0", + "seconds": 0.7360734998947009 + }, + { + "label": "all-fsw-1-1", + "seconds": 0.12865816697012633 + }, + { + "label": "full-core-2", + "seconds": 31.56791995896492, + "cpu_seconds": 292.7268020000001, + "compiles": 201, + "swig": 101, + "links": 1 + }, + { + "label": "all-fsw-2-0", + "seconds": 0.7702465410111472 + }, + { + "label": "all-fsw-2-1", + "seconds": 0.13073483295738697 + }, + { + "label": "no-change", + "seconds": 2.2769994999980554, + "cpu_seconds": 1.224788999999987, + "compiles": 0, + "swig": 0, + "links": 0 + } + ], + "native_bytes": 17441872 + } + }, + "representative_edits_and_imports": { + "separate": [ + { + "label": "one-fsw-after-link-0", + "seconds": 0.13300549995619804 + }, + { + "label": "one-fsw-after-link-1", + "seconds": 0.005422041984274983 + }, + { + "label": "change-c-0", + "seconds": 2.3422531249234453, + "cpu_seconds": 1.4768009999999592, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "label": "change-c-1", + "seconds": 2.4003415000624955, + "cpu_seconds": 1.4768180000000228, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "label": "change-c-2", + "seconds": 2.3428743749391288, + "cpu_seconds": 1.4855269999999337, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "label": "change-i-0", + "seconds": 4.175112541997805, + "cpu_seconds": 3.274405000000037, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "label": "change-i-1", + "seconds": 4.412642291979864, + "cpu_seconds": 3.324204000000009, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "label": "change-i-2", + "seconds": 4.220546459080651, + "cpu_seconds": 3.233866999999975, + "compiles": 1, + "swig": 1, + "links": 1 + } + ], + "combined": [ + { + "label": "one-fsw-after-link-0", + "seconds": 0.17261104192584753 + }, + { + "label": "one-fsw-after-link-1", + "seconds": 0.007891125045716763 + }, + { + "label": "change-c-0", + "seconds": 2.883739916025661, + "cpu_seconds": 1.7254409999999822, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "label": "change-c-1", + "seconds": 3.16395829198882, + "cpu_seconds": 1.8967580000000197, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "label": "change-c-2", + "seconds": 2.5354454589542, + "cpu_seconds": 1.814407999999986, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "label": "change-i-0", + "seconds": 4.27997212507762, + "cpu_seconds": 3.4315550000000172, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "label": "change-i-1", + "seconds": 4.286706542014144, + "cpu_seconds": 3.295378999999947, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "label": "change-i-2", + "seconds": 4.579222833039239, + "cpu_seconds": 3.29813400000004, + "compiles": 1, + "swig": 1, + "links": 1 + } + ] + }, + "pytest_collection": [ + { + "layout": "separate", + "temperature": "cold", + "exit_code": 5, + "wall_seconds": 43.356160625000484, + "ready_seconds": 41.24435783305671, + "workers": [ + { + "worker": "gw0", + "ready_seconds": 41.231810458004475, + "selected_items": 8398 + }, + { + "worker": "gw1", + "ready_seconds": 41.229059542063624, + "selected_items": 8398 + }, + { + "worker": "gw10", + "ready_seconds": 41.23895229201298, + "selected_items": 8398 + }, + { + "worker": "gw11", + "ready_seconds": 41.224768458050676, + "selected_items": 8398 + }, + { + "worker": "gw12", + "ready_seconds": 41.23262124997564, + "selected_items": 8398 + }, + { + "worker": "gw13", + "ready_seconds": 41.23374270799104, + "selected_items": 8398 + }, + { + "worker": "gw14", + "ready_seconds": 41.23574466700666, + "selected_items": 8398 + }, + { + "worker": "gw15", + "ready_seconds": 41.2276645420352, + "selected_items": 8398 + }, + { + "worker": "gw2", + "ready_seconds": 41.22190949996002, + "selected_items": 8398 + }, + { + "worker": "gw3", + "ready_seconds": 41.2280730829807, + "selected_items": 8398 + }, + { + "worker": "gw4", + "ready_seconds": 41.24435783305671, + "selected_items": 8398 + }, + { + "worker": "gw5", + "ready_seconds": 41.2371334170457, + "selected_items": 8398 + }, + { + "worker": "gw6", + "ready_seconds": 41.23996862501372, + "selected_items": 8398 + }, + { + "worker": "gw7", + "ready_seconds": 41.22230354195926, + "selected_items": 8398 + }, + { + "worker": "gw8", + "ready_seconds": 41.22526533296332, + "selected_items": 8398 + }, + { + "worker": "gw9", + "ready_seconds": 41.23372691706754, + "selected_items": 8398 + } + ], + "gw0": { + "fsw_modules": 98, + "distinct_fsw_files": 98, + "distinct_basilisk_files": 248, + "fsw_native_seconds": 6.680788291501813, + "simulation_native_seconds": 14.048979919054545 + } + }, + { + "layout": "separate", + "temperature": "warm", + "exit_code": 5, + "wall_seconds": 25.155796541948803, + "ready_seconds": 18.65421399998013, + "workers": [ + { + "worker": "gw0", + "ready_seconds": 17.428645959007554, + "selected_items": 8398 + }, + { + "worker": "gw1", + "ready_seconds": 17.431312249973416, + "selected_items": 8398 + }, + { + "worker": "gw10", + "ready_seconds": 17.42157187499106, + "selected_items": 8398 + }, + { + "worker": "gw11", + "ready_seconds": 17.41915933403652, + "selected_items": 8398 + }, + { + "worker": "gw12", + "ready_seconds": 17.733674874994904, + "selected_items": 8398 + }, + { + "worker": "gw13", + "ready_seconds": 17.430589042021893, + "selected_items": 8398 + }, + { + "worker": "gw14", + "ready_seconds": 18.65421399998013, + "selected_items": 8398 + }, + { + "worker": "gw15", + "ready_seconds": 18.65368774998933, + "selected_items": 8398 + }, + { + "worker": "gw2", + "ready_seconds": 17.42998749995604, + "selected_items": 8398 + }, + { + "worker": "gw3", + "ready_seconds": 17.44323691702448, + "selected_items": 8398 + }, + { + "worker": "gw4", + "ready_seconds": 17.43315762502607, + "selected_items": 8398 + }, + { + "worker": "gw5", + "ready_seconds": 17.42802491702605, + "selected_items": 8398 + }, + { + "worker": "gw6", + "ready_seconds": 17.427142999949865, + "selected_items": 8398 + }, + { + "worker": "gw7", + "ready_seconds": 17.422913459013216, + "selected_items": 8398 + }, + { + "worker": "gw8", + "ready_seconds": 17.438009166973643, + "selected_items": 8398 + }, + { + "worker": "gw9", + "ready_seconds": 17.423144249944016, + "selected_items": 8398 + } + ], + "gw0": { + "fsw_modules": 98, + "distinct_fsw_files": 98, + "distinct_basilisk_files": 248, + "fsw_native_seconds": 0.36247770802583545, + "simulation_native_seconds": 0.8597259152447805 + } + }, + { + "layout": "combined", + "temperature": "cold", + "exit_code": 5, + "wall_seconds": 29.957580749993213, + "ready_seconds": 27.963349916040897, + "workers": [ + { + "worker": "gw0", + "ready_seconds": 27.947418291005306, + "selected_items": 8398 + }, + { + "worker": "gw1", + "ready_seconds": 27.948502750019543, + "selected_items": 8398 + }, + { + "worker": "gw10", + "ready_seconds": 27.957529333070852, + "selected_items": 8398 + }, + { + "worker": "gw11", + "ready_seconds": 27.956519708037376, + "selected_items": 8398 + }, + { + "worker": "gw12", + "ready_seconds": 27.9541961660143, + "selected_items": 8398 + }, + { + "worker": "gw13", + "ready_seconds": 27.949767249985598, + "selected_items": 8398 + }, + { + "worker": "gw14", + "ready_seconds": 27.95073224999942, + "selected_items": 8398 + }, + { + "worker": "gw15", + "ready_seconds": 27.947367375018075, + "selected_items": 8398 + }, + { + "worker": "gw2", + "ready_seconds": 27.952040083007887, + "selected_items": 8398 + }, + { + "worker": "gw3", + "ready_seconds": 27.952748083043844, + "selected_items": 8398 + }, + { + "worker": "gw4", + "ready_seconds": 27.9496526659932, + "selected_items": 8398 + }, + { + "worker": "gw5", + "ready_seconds": 27.962191957980394, + "selected_items": 8398 + }, + { + "worker": "gw6", + "ready_seconds": 27.949636165983975, + "selected_items": 8398 + }, + { + "worker": "gw7", + "ready_seconds": 27.945228124968708, + "selected_items": 8398 + }, + { + "worker": "gw8", + "ready_seconds": 27.961985666071996, + "selected_items": 8398 + }, + { + "worker": "gw9", + "ready_seconds": 27.963349916040897, + "selected_items": 8398 + } + ], + "gw0": { + "fsw_modules": 98, + "distinct_fsw_files": 5, + "distinct_basilisk_files": 155, + "fsw_native_seconds": 0.6804928751662374, + "simulation_native_seconds": 8.391506380983628 + } + }, + { + "layout": "combined", + "temperature": "warm", + "exit_code": 5, + "wall_seconds": 19.521042834036052, + "ready_seconds": 17.505883916979656, + "workers": [ + { + "worker": "gw0", + "ready_seconds": 16.684906666981988, + "selected_items": 8398 + }, + { + "worker": "gw1", + "ready_seconds": 16.68122883408796, + "selected_items": 8398 + }, + { + "worker": "gw10", + "ready_seconds": 16.796559084090404, + "selected_items": 8398 + }, + { + "worker": "gw11", + "ready_seconds": 16.797202792018652, + "selected_items": 8398 + }, + { + "worker": "gw12", + "ready_seconds": 17.097346125054173, + "selected_items": 8398 + }, + { + "worker": "gw13", + "ready_seconds": 17.099171124980785, + "selected_items": 8398 + }, + { + "worker": "gw14", + "ready_seconds": 17.504039750085212, + "selected_items": 8398 + }, + { + "worker": "gw15", + "ready_seconds": 17.505883916979656, + "selected_items": 8398 + }, + { + "worker": "gw2", + "ready_seconds": 16.675224374979734, + "selected_items": 8398 + }, + { + "worker": "gw3", + "ready_seconds": 16.684862542082556, + "selected_items": 8398 + }, + { + "worker": "gw4", + "ready_seconds": 16.682094375020824, + "selected_items": 8398 + }, + { + "worker": "gw5", + "ready_seconds": 16.681778167025186, + "selected_items": 8398 + }, + { + "worker": "gw6", + "ready_seconds": 16.67874504206702, + "selected_items": 8398 + }, + { + "worker": "gw7", + "ready_seconds": 16.9235850420082, + "selected_items": 8398 + }, + { + "worker": "gw8", + "ready_seconds": 16.79652025003452, + "selected_items": 8398 + }, + { + "worker": "gw9", + "ready_seconds": 16.92345120909158, + "selected_items": 8398 + } + ], + "gw0": { + "fsw_modules": 98, + "distinct_fsw_files": 5, + "distinct_basilisk_files": 155, + "fsw_native_seconds": 0.05042074935045093, + "simulation_native_seconds": 0.675615374580957 + } + } + ] +} diff --git a/benchmarks/startup/results/macos-arm64-2026-10-01.rst b/benchmarks/startup/results/macos-arm64-2026-10-01.rst new file mode 100644 index 00000000000..0e5d86d2d61 --- /dev/null +++ b/benchmarks/startup/results/macos-arm64-2026-10-01.rst @@ -0,0 +1,161 @@ +Core FSW binding experiment +=========================== + +Measurements taken on 2026-10-01 with the core FSW prototype applied to +Basilisk commit ``b355572568fd1d53e42f8d1da51659ebf63eb66c``. +Timings, module counts, and validation results describe that prototype snapshot. + +See the accompanying `raw measurements `_. + +Implementation +-------------- + +The prototype combines 101 core C/C++ bindings into one native library. Each binding retains its original +Python names and initialization entry point. Generated private-module shims +load individual bindings on demand. Three OpenCV bindings and the Rust +binding remain separate. + +Results +------- + +.. list-table:: Full core group, seconds + :header-rows: 1 + + * - Measurement + - Separate + - Combined + * - Cold import, median of three + - 12.544 + - 0.770 + * - Warm import, median of three + - 0.260 + - 0.129 + * - FSW rebuild, median of three + - 30.670 + - 29.383 + +Trial ranges: + +- separate, cold: 10.439 to 13.411 seconds. +- separate, warm: 0.249 to 0.289 seconds. +- separate, build: 29.140 to 30.838 seconds. +- combined, cold: 0.736 to 0.953 seconds. +- combined, warm: 0.093 to 0.131 seconds. +- combined, build: 28.875 to 31.568 seconds. + +Build measurements regenerate all 101 SWIG wrappers, compile 201 objects, +and link the complete core FSW group. Shared Basilisk libraries are already +built, including the shared implementation used by ``dvAttEffect``. Both layouts +compile the same 201 objects. Separate mode links 101 native files; combined +mode links one. Build-time ranges overlap; there is no clear clean-build +penalty in these trials. An unchanged build compiles nothing. + +FSW native file size: 32.65 MiB separate; 16.63 MiB combined. + +Incremental builds +------------------ + +.. list-table:: Median of three, seconds including build-system overhead + :header-rows: 1 + + * - Edit + - Separate + - Combined + * - One mrpFeedback C source + - 2.343 + - 2.884 + * - One mrpFeedback SWIG interface + - 4.221 + - 4.287 + +Every one-source edit compiled exactly one object and relinked its library. +A SWIG interface edit also regenerated exactly one wrapper. A separate +integration check confirmed these properties and checked shared includes, +messaging, Rust wrappers, and unchanged builds. + +A single-module import sample after relinking was 0.133 seconds separate +versus 0.173 seconds combined (warm: 0.005 versus 0.008 seconds). This is +one sample per layout. A sparse script can pay more to open the larger +library even though unrelated Python bindings are not initialized. + +Pytest collection +----------------- + +.. list-table:: One A/B pair with 16 workers, seconds until collection completes + :header-rows: 1 + + * - State + - Separate + - Combined + * - All project native libraries freshly relinked + - 41.244 + - 27.963 + * - Subsequent warm run + - 18.654 + - 17.506 + +The profiler collected the CI-selected suite and cleared its items before +execution. Exit code 5 is expected for these collection-only measurements. +The same 98 FSW native modules were initialized in both layouts: 94 core +bindings and four optional bindings. Distinct loaded FSW files decreased +from 98 to five; all loaded Basilisk extension files decreased from 248 +to 155. Seven additional core bindings are grouped but were not loaded +during collection. Across the entire build graph, 271 native linker outputs +became 171, exactly the reduction from grouping 101 files into one. + +OS cache state and scheduling affect these results. For worker gw0, time +inside FSW native initialization decreased from 6.681 to 0.680 seconds, +while simulation native initialization also varied from 14.049 to 8.392 +seconds. The complete 13.3-second collection improvement cannot be assigned +solely to the FSW change from this one pair. The repeated, isolated FSW +imports above provide stronger evidence of the loading improvement. + +Validation +---------- + +- CI-selected Python suite: 8,394 passed, two skipped (tool discovery prerequisites). +- Native C/C++ suite: 270 passed. +- Rust workspace: 95 passed. +- Both layout switches passed compatibility and representative mrpFeedback tests. +- Original Python imports, private native imports, module identity, recorder + creation, and on-demand initialization passed dedicated tests. +- Extended incremental-build integration test passed all checks. +- Updated developer documentation parsed successfully with docutils. +- Built and repaired a wheel, installed it outside the checkout, and passed six + installed-package tests, including all 101 imports and message recorders. + +Method and limits +----------------- + +macOS 27.2 arm64; Python 3.14.6; CMake 4.3.2 with Ninja; AppleClang 21; +Release; strict warnings and OpenCV, MuJoCo, visualization, and Rust enabled. +Build parallelism: 12. Timing work ran sequentially. Dense-import timing +began after importing Basilisk to isolate FSW loading. Fresh processes were +used for cold and warm imports. No reboot or disk-cache flush was performed. +Cold denotes newly built or relinked native files, matching the post-build +question. Linux and Windows were not executed in this experiment. + +The incremental and single-module import samples came from an earlier run +that built targets for the 94 core modules discovered through pytest. The +combined library already contained all 101 bindings. Each representative +edit affected one object, so these incremental comparisons remain applicable. +All dense-import, complete-group build, and size measurements above use the +full 101-module group. + +The collection profiler also timed Python imports and subprocess calls. +Instrumentation adds overhead, and collection times should be compared +within this experiment. The portable benchmark now supplied in the repository +records native initialization and collection readiness only. + +The archived JSON includes all complete-group trials, representative incremental +and single-import samples, per-worker collection readiness, and native-loading +counts and times for worker gw0. Timing labels ending in ``-0`` and ``-1`` +in the archived import samples mean first and subsequent processes, respectively; +all times are in seconds and native sizes are in bytes. The portable command +uses explicit ``-cold`` and ``-warm`` labels for new runs. Local log paths, +process identifiers, and unrelated discarded timing attempts are omitted. + +This comparison used an earlier benchmark that could switch layouts. Current +measurement instructions are in the repository's +``docs/source/Support/Developer/performanceBenchmarks.rst``; the supported tool +measures the grouped layout only. diff --git a/benchmarks/startup/results/macos-arm64-simulation-2026-10-01.json b/benchmarks/startup/results/macos-arm64-simulation-2026-10-01.json new file mode 100644 index 00000000000..2cd263c2bbe --- /dev/null +++ b/benchmarks/startup/results/macos-arm64-simulation-2026-10-01.json @@ -0,0 +1,2029 @@ +{ + "base_commit": "f539f81d5d", + "date": "2026-10-01", + "experiments": { + "simulation": { + "schema_version": 1, + "group": "simulation", + "platform": "macOS-27.2-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "parallel": 12, + "original_layout": "combined", + "cache_settings": { + "CMAKE_GENERATOR": "Ninja", + "CMAKE_BUILD_TYPE": "Release", + "PY_LIMITED_API": "0x03090000", + "BSK_STRICT_WARNINGS": "ON", + "BUILD_OPNAV": "ON", + "BUILD_MUJOCO": "ON", + "BUILD_VIZINTERFACE": "ON", + "BUILD_RUST_MODULES": "ON" + }, + "measurements": [ + { + "layout": "separate", + "label": "settle", + "seconds": 38.58529820898548, + "compiles": 111, + "swig": 110, + "links": 110 + }, + { + "layout": "separate", + "label": "rebuild-0", + "seconds": 54.54542429104913, + "compiles": 230, + "swig": 110, + "links": 110 + }, + { + "layout": "separate", + "label": "all-simulation-0-cold", + "seconds": 12.471267041983083, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-simulation-0-warm", + "seconds": 0.2921315840212628, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "rebuild-1", + "seconds": 53.21752508310601, + "compiles": 230, + "swig": 110, + "links": 110 + }, + { + "layout": "separate", + "label": "all-simulation-1-cold", + "seconds": 12.431563708931208, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-simulation-1-warm", + "seconds": 0.2936484580859542, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "rebuild-2", + "seconds": 53.73415070795454, + "compiles": 230, + "swig": 110, + "links": 110 + }, + { + "layout": "separate", + "label": "all-simulation-2-cold", + "seconds": 12.14344350004103, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-simulation-2-warm", + "seconds": 0.3079792499775067, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "single-import-relink", + "seconds": 2.7791076669236645, + "compiles": 0, + "swig": 0, + "links": 110 + }, + { + "layout": "separate", + "label": "one-simulation-cold", + "seconds": 0.4792492500273511, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "one-simulation-warm", + "seconds": 0.01872345805168152, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "relink-0", + "seconds": 2.7661254999693483, + "compiles": 0, + "swig": 0, + "links": 110 + }, + { + "layout": "separate", + "label": "change-cpp-0", + "seconds": 3.7636569580063224, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-0", + "seconds": 5.495616916916333, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "relink-1", + "seconds": 2.692812417051755, + "compiles": 0, + "swig": 0, + "links": 110 + }, + { + "layout": "separate", + "label": "change-cpp-1", + "seconds": 3.698511915979907, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-1", + "seconds": 5.2884906670078635, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "relink-2", + "seconds": 2.8148240839364007, + "compiles": 0, + "swig": 0, + "links": 110 + }, + { + "layout": "separate", + "label": "change-cpp-2", + "seconds": 3.8630143330665305, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-2", + "seconds": 5.690214209025726, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "no-change", + "seconds": 1.6763553339987993, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "separate", + "label": "native-size", + "bytes": 46801784 + }, + { + "layout": "separate", + "label": "prepare-collection", + "seconds": 7.94446174998302, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "separate", + "label": "all-native-relink", + "seconds": 3.623706249985844, + "compiles": 0, + "swig": 0, + "links": 114 + }, + { + "layout": "separate", + "label": "pytest-cold", + "seconds": 27.775981903076172, + "workers": 16, + "workers_summary": [ + { + "worker": "gw2", + "ready_seconds": 27.764750957489014, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.408209001412615 + }, + { + "worker": "controller", + "ready_seconds": null, + "selected_items": null, + "exit_code": 5, + "basilisk_native_initializations": 0, + "basilisk_native_modules": 0, + "basilisk_native_files": 0, + "basilisk_native_seconds": 0 + }, + { + "worker": "gw3", + "ready_seconds": 27.758769989013672, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.418112759827636 + }, + { + "worker": "gw8", + "ready_seconds": 27.775981903076172, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.1347486958839 + }, + { + "worker": "gw4", + "ready_seconds": 27.75963592529297, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.458986464189366 + }, + { + "worker": "gw11", + "ready_seconds": 27.770444869995117, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 10.861487795598805 + }, + { + "worker": "gw10", + "ready_seconds": 27.76478385925293, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 10.90486979263369 + }, + { + "worker": "gw5", + "ready_seconds": 27.76132106781006, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.233709672582336 + }, + { + "worker": "gw9", + "ready_seconds": 27.763307094573975, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.026158379856497 + }, + { + "worker": "gw13", + "ready_seconds": 27.763234853744507, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 10.777525292243809 + }, + { + "worker": "gw6", + "ready_seconds": 27.76799488067627, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.24436238547787 + }, + { + "worker": "gw7", + "ready_seconds": 27.76256012916565, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.16732162120752 + }, + { + "worker": "gw12", + "ready_seconds": 27.772336959838867, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 10.778479337808676 + }, + { + "worker": "gw15", + "ready_seconds": 27.762006759643555, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 10.640406500780955 + }, + { + "worker": "gw0", + "ready_seconds": 27.76555609703064, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.46609654580243 + }, + { + "worker": "gw1", + "ready_seconds": 27.762355089187622, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 11.401438124477863 + }, + { + "worker": "gw14", + "ready_seconds": 27.75701379776001, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 10.768934137537144 + } + ] + }, + { + "layout": "separate", + "label": "pytest-warm", + "seconds": 15.731069803237915, + "workers": 16, + "workers_summary": [ + { + "worker": "gw2", + "ready_seconds": 15.721938133239746, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6025372541043907 + }, + { + "worker": "controller", + "ready_seconds": null, + "selected_items": null, + "exit_code": 5, + "basilisk_native_initializations": 0, + "basilisk_native_modules": 0, + "basilisk_native_files": 0, + "basilisk_native_seconds": 0 + }, + { + "worker": "gw3", + "ready_seconds": 15.717881917953491, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6104593336349353 + }, + { + "worker": "gw8", + "ready_seconds": 15.718528985977173, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.5926485480740666 + }, + { + "worker": "gw4", + "ready_seconds": 15.72380518913269, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6173520145239308 + }, + { + "worker": "gw11", + "ready_seconds": 15.709326028823853, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.5968044046312571 + }, + { + "worker": "gw10", + "ready_seconds": 15.720115184783936, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.5964901613770053 + }, + { + "worker": "gw5", + "ready_seconds": 15.726864099502563, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6118323853006586 + }, + { + "worker": "gw9", + "ready_seconds": 15.720618963241577, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.5994653787929565 + }, + { + "worker": "gw13", + "ready_seconds": 15.717519998550415, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6016296072630212 + }, + { + "worker": "gw6", + "ready_seconds": 15.731069803237915, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6059920896077529 + }, + { + "worker": "gw7", + "ready_seconds": 15.725157976150513, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6160192564129829 + }, + { + "worker": "gw12", + "ready_seconds": 15.725789785385132, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6160662637557834 + }, + { + "worker": "gw15", + "ready_seconds": 15.720587015151978, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.5949604437919334 + }, + { + "worker": "gw0", + "ready_seconds": 15.715095043182373, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6037529193563387 + }, + { + "worker": "gw1", + "ready_seconds": 15.723897933959961, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.6238600850338116 + }, + { + "worker": "gw14", + "ready_seconds": 15.72731900215149, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 137, + "basilisk_native_seconds": 0.600101591902785 + } + ] + }, + { + "layout": "combined", + "label": "settle", + "seconds": 37.369503542082384, + "compiles": 110, + "swig": 110, + "links": 1 + }, + { + "layout": "combined", + "label": "rebuild-0", + "seconds": 53.39677441702224, + "compiles": 230, + "swig": 110, + "links": 1 + }, + { + "layout": "combined", + "label": "all-simulation-0-cold", + "seconds": 1.3394590839743614, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-simulation-0-warm", + "seconds": 0.2232758750906214, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "rebuild-1", + "seconds": 55.97711374994833, + "compiles": 230, + "swig": 110, + "links": 1 + }, + { + "layout": "combined", + "label": "all-simulation-1-cold", + "seconds": 1.211828959058039, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-simulation-1-warm", + "seconds": 0.14643700001761317, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "rebuild-2", + "seconds": 56.44143924990203, + "compiles": 230, + "swig": 110, + "links": 1 + }, + { + "layout": "combined", + "label": "all-simulation-2-cold", + "seconds": 1.1662696249550208, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-simulation-2-warm", + "seconds": 0.15267391700763255, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "single-import-relink", + "seconds": 1.6651892500231043, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "one-simulation-cold", + "seconds": 0.29739312501624227, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "one-simulation-warm", + "seconds": 0.01657749991863966, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "relink-0", + "seconds": 1.8346663339762017, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-0", + "seconds": 3.8043699159752578, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-0", + "seconds": 5.338286999962293, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "relink-1", + "seconds": 1.554354834021069, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-1", + "seconds": 3.8839625410037115, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-1", + "seconds": 5.3776689999504015, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "relink-2", + "seconds": 1.5636684170458466, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-2", + "seconds": 3.901816957979463, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-2", + "seconds": 5.248088499996811, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "no-change", + "seconds": 1.4477397910086438, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "combined", + "label": "native-size", + "bytes": 31566136 + }, + { + "layout": "combined", + "label": "prepare-collection", + "seconds": 7.738923541968688, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "combined", + "label": "all-native-relink", + "seconds": 2.7565993749303743, + "compiles": 0, + "swig": 0, + "links": 16 + }, + { + "layout": "combined", + "label": "pytest-cold", + "seconds": 21.1077880859375, + "workers": 16, + "workers_summary": [ + { + "worker": "gw2", + "ready_seconds": 21.090054988861084, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.420543661923148 + }, + { + "worker": "controller", + "ready_seconds": null, + "selected_items": null, + "exit_code": 5, + "basilisk_native_initializations": 0, + "basilisk_native_modules": 0, + "basilisk_native_files": 0, + "basilisk_native_seconds": 0 + }, + { + "worker": "gw3", + "ready_seconds": 21.092225074768066, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.3961047971388325 + }, + { + "worker": "gw8", + "ready_seconds": 21.094527006149292, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.8955211692955345 + }, + { + "worker": "gw4", + "ready_seconds": 21.092665910720825, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.220443584723398 + }, + { + "worker": "gw11", + "ready_seconds": 21.09398889541626, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.7247993734199554 + }, + { + "worker": "gw10", + "ready_seconds": 21.09781002998352, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.7790410190355033 + }, + { + "worker": "gw5", + "ready_seconds": 21.097290992736816, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.125551639357582 + }, + { + "worker": "gw9", + "ready_seconds": 21.091437101364136, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.8605704720830545 + }, + { + "worker": "gw13", + "ready_seconds": 21.10641312599182, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.6278716364176944 + }, + { + "worker": "gw6", + "ready_seconds": 21.1077880859375, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.129365969332866 + }, + { + "worker": "gw7", + "ready_seconds": 21.091958045959473, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.961246325285174 + }, + { + "worker": "gw12", + "ready_seconds": 21.097980976104736, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.6636454209219664 + }, + { + "worker": "gw15", + "ready_seconds": 21.089441061019897, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.5742013469571248 + }, + { + "worker": "gw0", + "ready_seconds": 21.094808101654053, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.425065833260305 + }, + { + "worker": "gw1", + "ready_seconds": 21.099969148635864, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 4.416435792343691 + }, + { + "worker": "gw14", + "ready_seconds": 21.1043221950531, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 3.558767587528564 + } + ] + }, + { + "layout": "combined", + "label": "pytest-warm", + "seconds": 16.729196071624756, + "workers": 16, + "workers_summary": [ + { + "worker": "gw2", + "ready_seconds": 15.496830940246582, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.1939801707630977 + }, + { + "worker": "controller", + "ready_seconds": null, + "selected_items": null, + "exit_code": 5, + "basilisk_native_initializations": 0, + "basilisk_native_modules": 0, + "basilisk_native_files": 0, + "basilisk_native_seconds": 0 + }, + { + "worker": "gw3", + "ready_seconds": 15.498321771621704, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19685687904711813 + }, + { + "worker": "gw8", + "ready_seconds": 15.884070873260498, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.20027362427208573 + }, + { + "worker": "gw4", + "ready_seconds": 15.497673988342285, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.1943542065564543 + }, + { + "worker": "gw11", + "ready_seconds": 16.108214616775513, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.20239720540121198 + }, + { + "worker": "gw10", + "ready_seconds": 15.883609771728516, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19651904748752713 + }, + { + "worker": "gw5", + "ready_seconds": 15.494120836257935, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.1927734890487045 + }, + { + "worker": "gw9", + "ready_seconds": 15.883251905441284, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.20404641202185303 + }, + { + "worker": "gw13", + "ready_seconds": 16.107277870178223, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.20002124353777617 + }, + { + "worker": "gw6", + "ready_seconds": 15.884185791015625, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19720112346112728 + }, + { + "worker": "gw7", + "ready_seconds": 15.883992671966553, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19548636570107192 + }, + { + "worker": "gw12", + "ready_seconds": 16.10839295387268, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19989154022186995 + }, + { + "worker": "gw15", + "ready_seconds": 16.729196071624756, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.2017183443531394 + }, + { + "worker": "gw0", + "ready_seconds": 15.502067804336548, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.1937610092572868 + }, + { + "worker": "gw1", + "ready_seconds": 15.508753776550293, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19708294270094484 + }, + { + "worker": "gw14", + "ready_seconds": 16.10819983482361, + "selected_items": 8421, + "exit_code": 5, + "basilisk_native_initializations": 397, + "basilisk_native_modules": 397, + "basilisk_native_files": 28, + "basilisk_native_seconds": 0.19725166354328394 + } + ] + }, + { + "layout": "combined", + "label": "restore", + "seconds": 1.7305348330410197, + "compiles": 0, + "swig": 0, + "links": 0 + } + ], + "modules": [ + "simulation.ExtPulsedTorque", + "simulation.GravityGradientEffector", + "simulation.MtbEffector", + "simulation.RWConfigPayload", + "simulation.ReactionWheelPower", + "simulation.THRSimConfig", + "simulation.albedo", + "simulation.antennaPower", + "simulation.arrayMotorTorqueToSingleActuators", + "simulation.boreAngCalc", + "simulation.cannonballDrag", + "simulation.coarseSunSensor", + "simulation.constraintDynamicEffector", + "simulation.dataFileToViz", + "simulation.dentonFluxModel", + "simulation.downlinkHandling", + "simulation.dragDynamicEffector", + "simulation.dualHingedRigidBodyStateEffector", + "simulation.dynParamManager", + "simulation.dynamicObject", + "simulation.earthRadiationModel", + "simulation.eclipse", + "simulation.encoder", + "simulation.ephemerisConverter", + "simulation.exponentialAtmosphere", + "simulation.extForceTorque", + "simulation.facetDragDynamicEffector", + "simulation.facetSRPDynamicEffector", + "simulation.facetedSRPEffector", + "simulation.facetedSpacecraftModel", + "simulation.facetedSpacecraftProjectedArea", + "simulation.fuelTank", + "simulation.gravityEffector", + "simulation.gravityModel", + "simulation.groundLocation", + "simulation.groundMapping", + "simulation.hingedBodyLinearProfiler", + "simulation.hingedRigidBodyMotor", + "simulation.hingedRigidBodyMotorSensor", + "simulation.hingedRigidBodyStateEffector", + "simulation.igbmNoiseStateEffector", + "simulation.imuSensor", + "simulation.jointArrayRefProfiler", + "simulation.linearSpringMassDamper", + "simulation.linearTranslationNDOFStateEffector", + "simulation.linearTranslationOneDOFStateEffector", + "simulation.linkBudget", + "simulation.magneticFieldCenteredDipole", + "simulation.magneticFieldWMM", + "simulation.magnetometer", + "simulation.mappingInstrument", + "simulation.meanRevertingNoiseStateEffector", + "simulation.motorThermal", + "simulation.motorVoltageInterface", + "simulation.msisAtmosphere", + "simulation.msmForceTorque", + "simulation.nHingedRigidBodyStateEffector", + "simulation.orbElemConvert", + "simulation.partitionedStorageUnit", + "simulation.pinholeCamera", + "simulation.planetEphemeris", + "simulation.planetHeading", + "simulation.planetNav", + "simulation.pointMassGravityModel", + "simulation.polyhedralGravityModel", + "simulation.prescribedLinearTranslation", + "simulation.prescribedMotionStateEffector", + "simulation.prescribedRotation1DOF", + "simulation.radiationPressure", + "simulation.reactionWheelStateEffector", + "simulation.saturationSingleActuator", + "simulation.scalarJointStatesToRWSpeed", + "simulation.sensorThermal", + "simulation.simSynch", + "simulation.simpleAntenna", + "simulation.simpleBattery", + "simulation.simpleInstrument", + "simulation.simpleMassProps", + "simulation.simpleNav", + "simulation.simplePowerMonitor", + "simulation.simplePowerSink", + "simulation.simpleSolarPanel", + "simulation.simpleStorageUnit", + "simulation.simpleTransmitter", + "simulation.simpleVoltEstimator", + "simulation.solarFlux", + "simulation.spaceToGroundTransmitter", + "simulation.spaceWeatherData", + "simulation.spacecraft", + "simulation.spacecraftChargingDynamics", + "simulation.spacecraftChargingEquilibrium", + "simulation.spacecraftLocation", + "simulation.sphericalHarmonicsGravityModel", + "simulation.sphericalPendulum", + "simulation.spiceInterface", + "simulation.spinningBodyNDOFStateEffector", + "simulation.spinningBodyOneDOFStateEffector", + "simulation.spinningBodyTwoDOFStateEffector", + "simulation.starTracker", + "simulation.stateArchitecture", + "simulation.stepperMotor", + "simulation.stripLocation", + "simulation.svIntegrators", + "simulation.tabularAtmosphere", + "simulation.tempMeasurement", + "simulation.thrusterDynamicEffector", + "simulation.thrusterStateEffector", + "simulation.twoHingeDamper", + "simulation.vscmgStateEffector", + "simulation.zeroWindModel" + ], + "restored_original_layout": true, + "initial_binding_groups": { + "fsw": true, + "simulation": true, + "mujoco": true, + "opnav": true + } + }, + "mujoco": { + "schema_version": 1, + "group": "mujoco", + "platform": "macOS-27.2-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "parallel": 12, + "original_layout": "combined", + "cache_settings": { + "CMAKE_GENERATOR": "Ninja", + "CMAKE_BUILD_TYPE": "Release", + "PY_LIMITED_API": "0x03090000", + "BSK_STRICT_WARNINGS": "ON", + "BUILD_OPNAV": "ON", + "BUILD_MUJOCO": "ON", + "BUILD_VIZINTERFACE": "ON", + "BUILD_RUST_MODULES": "ON" + }, + "measurements": [ + { + "layout": "separate", + "label": "settle", + "seconds": 10.668758832965977, + "compiles": 30, + "swig": 16, + "links": 16 + }, + { + "layout": "separate", + "label": "rebuild-0", + "seconds": 10.793276541051455, + "compiles": 30, + "swig": 16, + "links": 16 + }, + { + "layout": "separate", + "label": "all-mujoco-0-cold", + "seconds": 1.9604169590165839, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-mujoco-0-warm", + "seconds": 0.31654604198411107, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "rebuild-1", + "seconds": 11.288818000000902, + "compiles": 30, + "swig": 16, + "links": 16 + }, + { + "layout": "separate", + "label": "all-mujoco-1-cold", + "seconds": 2.0968890000367537, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-mujoco-1-warm", + "seconds": 0.2845218749716878, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "rebuild-2", + "seconds": 10.619356833980419, + "compiles": 30, + "swig": 16, + "links": 16 + }, + { + "layout": "separate", + "label": "all-mujoco-2-cold", + "seconds": 2.004357500001788, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-mujoco-2-warm", + "seconds": 0.2806807910092175, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "single-import-relink", + "seconds": 2.3133775420719758, + "compiles": 0, + "swig": 0, + "links": 16 + }, + { + "layout": "separate", + "label": "one-mujoco-cold", + "seconds": 0.04786558402702212, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "one-mujoco-warm", + "seconds": 0.006560374982655048, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "relink-0", + "seconds": 2.391989666968584, + "compiles": 0, + "swig": 0, + "links": 16 + }, + { + "layout": "separate", + "label": "change-cpp-0", + "seconds": 2.892686542007141, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-0", + "seconds": 4.488686166936532, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "relink-1", + "seconds": 2.404434208991006, + "compiles": 0, + "swig": 0, + "links": 16 + }, + { + "layout": "separate", + "label": "change-cpp-1", + "seconds": 3.083998000016436, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-1", + "seconds": 4.596163875074126, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "relink-2", + "seconds": 2.4135407500434667, + "compiles": 0, + "swig": 0, + "links": 16 + }, + { + "layout": "separate", + "label": "change-cpp-2", + "seconds": 3.6964960829354823, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-2", + "seconds": 4.247885124990717, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "no-change", + "seconds": 2.3559342090738937, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "separate", + "label": "native-size", + "bytes": 3300760 + }, + { + "layout": "combined", + "label": "settle", + "seconds": 9.604941166937351, + "compiles": 16, + "swig": 16, + "links": 1 + }, + { + "layout": "combined", + "label": "rebuild-0", + "seconds": 10.333400000003166, + "compiles": 30, + "swig": 16, + "links": 1 + }, + { + "layout": "combined", + "label": "all-mujoco-0-cold", + "seconds": 0.6039371669758111, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-mujoco-0-warm", + "seconds": 0.24603029096033424, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "rebuild-1", + "seconds": 10.420361416065134, + "compiles": 30, + "swig": 16, + "links": 1 + }, + { + "layout": "combined", + "label": "all-mujoco-1-cold", + "seconds": 0.6032775409985334, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-mujoco-1-warm", + "seconds": 0.24236537504475564, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "rebuild-2", + "seconds": 10.45137199992314, + "compiles": 30, + "swig": 16, + "links": 1 + }, + { + "layout": "combined", + "label": "all-mujoco-2-cold", + "seconds": 0.49461383395828307, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-mujoco-2-warm", + "seconds": 0.23347266705241054, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "single-import-relink", + "seconds": 2.044273208011873, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "one-mujoco-cold", + "seconds": 0.07695449993479997, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "one-mujoco-warm", + "seconds": 0.008311250014230609, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "relink-0", + "seconds": 2.1518137080129236, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-0", + "seconds": 3.0398745420388877, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-0", + "seconds": 4.524019374977797, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "relink-1", + "seconds": 2.126827207976021, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-1", + "seconds": 3.067950625088997, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-1", + "seconds": 4.463286333018914, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "relink-2", + "seconds": 2.0973062500124797, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-2", + "seconds": 2.954796083038673, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-2", + "seconds": 4.536916458979249, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "no-change", + "seconds": 2.023637584061362, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "combined", + "label": "native-size", + "bytes": 2125808 + }, + { + "layout": "combined", + "label": "restore", + "seconds": 2.08732254197821, + "compiles": 0, + "swig": 0, + "links": 0 + } + ], + "modules": [ + "simulation.JointPIDController", + "simulation.MJJointReactionForces", + "simulation.MJSystemCoM", + "simulation.MJSystemMassMatrix", + "simulation.NBodyGravity", + "simulation.StatefulSysModel", + "simulation.cmdForceInertialToForceAtSite", + "simulation.igbmAtmDensity", + "simulation.inhomogeneousGeometricBrownianMotion", + "simulation.linearTimeInvariantSystem", + "simulation.meanRevertingNoise", + "simulation.mujoco", + "simulation.pyStatefulNumbaModel", + "simulation.stochasticAtmDensity", + "simulation.stochasticDragCoeff", + "simulation.thrOnTimeToForce" + ], + "restored_original_layout": true, + "initial_binding_groups": { + "fsw": true, + "simulation": true, + "mujoco": true, + "opnav": true + } + }, + "opnav": { + "schema_version": 1, + "group": "opnav", + "platform": "macOS-27.2-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "parallel": 12, + "original_layout": "combined", + "cache_settings": { + "CMAKE_GENERATOR": "Ninja", + "CMAKE_BUILD_TYPE": "Release", + "PY_LIMITED_API": "0x03090000", + "BSK_STRICT_WARNINGS": "ON", + "BUILD_OPNAV": "ON", + "BUILD_MUJOCO": "ON", + "BUILD_VIZINTERFACE": "ON", + "BUILD_RUST_MODULES": "ON" + }, + "measurements": [ + { + "layout": "separate", + "label": "settle", + "seconds": 4.979767125099897, + "compiles": 8, + "swig": 4, + "links": 4 + }, + { + "layout": "separate", + "label": "rebuild-0", + "seconds": 4.888936375034973, + "compiles": 8, + "swig": 4, + "links": 4 + }, + { + "layout": "separate", + "label": "all-opnav-0-cold", + "seconds": 0.6064414579886943, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-opnav-0-warm", + "seconds": 0.018203999963589013, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "rebuild-1", + "seconds": 4.952402500086464, + "compiles": 8, + "swig": 4, + "links": 4 + }, + { + "layout": "separate", + "label": "all-opnav-1-cold", + "seconds": 0.5569009580649436, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-opnav-1-warm", + "seconds": 0.016690082964487374, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "rebuild-2", + "seconds": 4.923714000033215, + "compiles": 8, + "swig": 4, + "links": 4 + }, + { + "layout": "separate", + "label": "all-opnav-2-cold", + "seconds": 0.5011265000794083, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "all-opnav-2-warm", + "seconds": 0.018271500011906028, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "single-import-relink", + "seconds": 2.244955708971247, + "compiles": 0, + "swig": 0, + "links": 4 + }, + { + "layout": "separate", + "label": "one-opnav-cold", + "seconds": 0.11445304192602634, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "one-opnav-warm", + "seconds": 0.010176457930356264, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "separate", + "label": "relink-0", + "seconds": 2.228305457974784, + "compiles": 0, + "swig": 0, + "links": 4 + }, + { + "layout": "separate", + "label": "change-cpp-0", + "seconds": 3.4670299170538783, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-0", + "seconds": 4.7914097079774365, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "relink-1", + "seconds": 2.2519058330217376, + "compiles": 0, + "swig": 0, + "links": 4 + }, + { + "layout": "separate", + "label": "change-cpp-1", + "seconds": 3.423586625023745, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-1", + "seconds": 4.810178583022207, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "relink-2", + "seconds": 2.1716481250477955, + "compiles": 0, + "swig": 0, + "links": 4 + }, + { + "layout": "separate", + "label": "change-cpp-2", + "seconds": 3.484631958999671, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "separate", + "label": "change-i-2", + "seconds": 4.739962749998085, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "separate", + "label": "no-change", + "seconds": 2.0000061659375206, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "separate", + "label": "native-size", + "bytes": 26191944 + }, + { + "layout": "combined", + "label": "settle", + "seconds": 5.011741625028662, + "compiles": 4, + "swig": 4, + "links": 1 + }, + { + "layout": "combined", + "label": "rebuild-0", + "seconds": 4.917923124972731, + "compiles": 8, + "swig": 4, + "links": 1 + }, + { + "layout": "combined", + "label": "all-opnav-0-cold", + "seconds": 0.3519438330549747, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-opnav-0-warm", + "seconds": 0.015767166041769087, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "rebuild-1", + "seconds": 4.917397291981615, + "compiles": 8, + "swig": 4, + "links": 1 + }, + { + "layout": "combined", + "label": "all-opnav-1-cold", + "seconds": 0.24106408294755965, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-opnav-1-warm", + "seconds": 0.01428254193160683, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "rebuild-2", + "seconds": 4.887247374979779, + "compiles": 8, + "swig": 4, + "links": 1 + }, + { + "layout": "combined", + "label": "all-opnav-2-cold", + "seconds": 0.21086216601543128, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "all-opnav-2-warm", + "seconds": 0.015192124992609024, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "single-import-relink", + "seconds": 2.1969972499646246, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "one-opnav-cold", + "seconds": 0.21895858296193182, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "one-opnav-warm", + "seconds": 0.012672416982240975, + "python_version": "3.14.6 (v3.14.6:c63aec69bd5, Jun 10 2026, 08:07:54) [Clang 21.0.0 (clang-2100.1.1.101)]" + }, + { + "layout": "combined", + "label": "relink-0", + "seconds": 2.177755083073862, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-0", + "seconds": 3.486091124941595, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-0", + "seconds": 4.729505915893242, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "relink-1", + "seconds": 2.1813573339022696, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-1", + "seconds": 3.4557851660065353, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-1", + "seconds": 4.732263165991753, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "relink-2", + "seconds": 2.150751458015293, + "compiles": 0, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-cpp-2", + "seconds": 3.5621825830312446, + "compiles": 1, + "swig": 0, + "links": 1 + }, + { + "layout": "combined", + "label": "change-i-2", + "seconds": 4.734462624997832, + "compiles": 1, + "swig": 1, + "links": 1 + }, + { + "layout": "combined", + "label": "no-change", + "seconds": 2.0568565420107916, + "compiles": 0, + "swig": 0, + "links": 0 + }, + { + "layout": "combined", + "label": "native-size", + "bytes": 10817696 + }, + { + "layout": "combined", + "label": "restore", + "seconds": 1.9860346249770373, + "compiles": 0, + "swig": 0, + "links": 0 + } + ], + "modules": [ + "fswAlgorithms.centerRadiusCNN", + "fswAlgorithms.houghCircles", + "fswAlgorithms.limbFinding", + "simulation.camera" + ], + "restored_original_layout": true, + "initial_binding_groups": { + "fsw": true, + "simulation": true, + "mujoco": true, + "opnav": true + } + } + } +} diff --git a/benchmarks/startup/results/macos-arm64-simulation-2026-10-01.rst b/benchmarks/startup/results/macos-arm64-simulation-2026-10-01.rst new file mode 100644 index 00000000000..46d500d91e7 --- /dev/null +++ b/benchmarks/startup/results/macos-arm64-simulation-2026-10-01.rst @@ -0,0 +1,228 @@ +Simulation and optional binding experiment +=========================================== + +Measurements taken on 2026-10-01 with the prototype applied to Basilisk +commit ``f539f81d5d``. See the accompanying +`raw measurements `_. +Timings, module counts, and validation results describe that prototype snapshot. + +Packaging +--------- + +The experiment adds three independently loaded native containers: + +- 110 core simulation bindings in ``Basilisk.simulation._simulationCoreNative``. +- All 16 MuJoCo bindings in ``Basilisk.simulation._mujocoNative``. +- All four OpenCV bindings (``camera``, ``centerRadiusCNN``, ``houghCircles``, + and ``limbFinding``) in ``Basilisk._opNavNative``. This container spans the + simulation and FSW Python packages and supports the optional ``bsk-opnav`` wheel. + +Public Python imports, constructors, messages, and recorders retain their existing +APIs. Each private extension keeps its original initialization entry point and is +initialized only when imported. MuJoCo public aliases retain their existing names. +The core simulation container does not link MuJoCo or OpenCV libraries. Vizard, +external modules, and Rust bindings retain their existing packaging. + +Cold imports and builds +----------------------- + +.. list-table:: Median of three trials, seconds + :header-rows: 1 + + * - Group + - Separate cold import + - Combined cold import + - Separate rebuild + - Combined rebuild + * - Core simulation + - 12.432 + - 1.212 + - 53.734 + - 55.977 + * - MuJoCo + - 2.004 + - 0.603 + - 10.793 + - 10.420 + * - OpenCV + - 0.557 + - 0.241 + - 4.924 + - 4.917 + +Each full-group rebuild regenerates every selected SWIG wrapper and recompiles +all of that group's objects: 230 for simulation, 30 for MuJoCo, and eight for +OpenCV. Shared implementation libraries and third-party dependencies are already +built. These are binding-group rebuild times, not whole-project clean-build times. +The aggregate's empty Xcode anchor is already compiled before each timed rebuild. + +The simulation median increased by about 2.2 seconds (4.2%); its trial ranges +overlap. MuJoCo and OpenCV show no build-time penalty in this small sample. + +.. list-table:: Trial ranges, seconds + :header-rows: 1 + + * - Group and layout + - Cold import range + - Rebuild range + * - Core simulation, separate + - 12.143 to 12.471 + - 53.218 to 54.545 + * - Core simulation, combined + - 1.166 to 1.339 + - 53.397 to 56.441 + * - MuJoCo, separate + - 1.960 to 2.097 + - 10.619 to 11.289 + * - MuJoCo, combined + - 0.495 to 0.604 + - 10.333 to 10.451 + * - OpenCV, separate + - 0.501 to 0.606 + - 4.889 to 4.952 + * - OpenCV, combined + - 0.211 to 0.352 + - 4.887 to 4.918 + +.. list-table:: Warm import medians and native container sizes + :header-rows: 1 + + * - Group + - Separate warm (s) + - Combined warm (s) + - Separate size (MiB) + - Combined size (MiB) + * - Core simulation + - 0.294 + - 0.153 + - 44.63 + - 30.10 + * - MuJoCo + - 0.285 + - 0.242 + - 3.15 + - 2.03 + * - OpenCV + - 0.018 + - 0.015 + - 24.98 + - 10.32 + +Incremental builds +------------------ + +.. list-table:: Median of three trials, seconds including build-system overhead + :header-rows: 1 + + * - Representative module + - Separate C++ edit + - Combined C++ edit + - Separate interface edit + - Combined interface edit + * - spacecraft + - 3.764 + - 3.884 + - 5.496 + - 5.338 + * - thrOnTimeToForce + - 3.084 + - 3.040 + - 4.489 + - 4.524 + * - camera + - 3.467 + - 3.486 + - 4.791 + - 4.732 + +Every measured edit compiled exactly one object and relinked one library. An +interface edit also regenerated exactly one SWIG wrapper. All unchanged builds +performed zero compilations, zero SWIG generations, and zero links. Changing one +binding therefore does not recompile its neighbors, but does replace the combined +library and can incur its first-load validation again. + +Sparse imports +-------------- + +.. list-table:: One cold-import sample per layout, seconds + :header-rows: 1 + + * - Public module + - Separate + - Combined + * - spacecraft + - 0.479 + - 0.297 + * - thrOnTimeToForce + - 0.048 + - 0.077 + * - camera + - 0.114 + - 0.219 + +A sparse script can pay more to open a larger container even though unrelated +bindings remain uninitialized. The optional single-module samples demonstrate +that tradeoff; they are not repeated estimates of a typical application's latency. + +Pytest startup +-------------- + +.. list-table:: One comparison with 16 workers, seconds until collection completes + :header-rows: 1 + + * - State + - Separate simulation + - Combined simulation + * - All project native outputs freshly relinked + - 27.776 + - 21.108 + * - Subsequent warm run + - 15.731 + - 16.729 + +FSW, MuJoCo, and OpenCV grouping remained enabled in both cases. Only the core +simulation layout changed. Both layouts collected 8,421 CI-selected tests and +initialized the same 397 Basilisk native modules per worker. Their distinct loaded +native files decreased from 137 to 28. Cold collection improved by about 24%; +there was no warm-collection improvement in this single pair. + +The profiler clears collected items before execution. These runs measure startup, +not test execution, and exit code 5 is expected. The archived JSON retains per-worker +readiness and native-load totals without machine-specific absolute file paths. + +Validation +---------- + +- Simulation, image-processing, import compatibility, and optional-wheel regression + tests: 4,221 passed, including all 152 MuJoCo tests. +- Example scenarios: 270 passed. +- Final binding, packaging, cleanup, and benchmark regressions: 64 passed, + covering Ninja, Unix Makefiles, and Xcode and unchanged reconfiguration of + every group. +- Disabling MuJoCo and OpenCV removes their containers, loaders, and private + shims without changing the core simulation library. Restoring both features + rebuilds the optional containers successfully. +- Built and repaired a wheel containing all 130 new grouped bindings, installed + it outside the checkout, and passed 19 installed-package tests, including a + MuJoCo simulation with Python callbacks across native bindings. + +Method and limits +----------------- + +macOS 27.2 arm64; Python 3.14.6; CMake 4.3.2 with Ninja; AppleClang 21; +Release; strict warnings, OpenCV, MuJoCo, visualization, and Rust enabled. +Build parallelism: 12. Each experiment changes only its selected group's layout; +all other groups remain combined. Build and timing work ran sequentially. + +Import timing starts after importing Basilisk and imports every public proxy in +the selected group. MuJoCo's import also includes its Python-side dependencies. +Cold means the first process after rebuilding or relinking the relevant native +files. Warm means the next fresh process. No reboot or disk-cache flush was used. +OS cache state, scheduling, and instrumentation affect timings; three trials and +one collection comparison do not establish a cross-platform performance guarantee. +Linux and Windows were not exercised in these timing measurements. + +This comparison used an earlier benchmark that could switch layouts. Current +measurement instructions are in the repository's +``docs/source/Support/Developer/performanceBenchmarks.rst``; the supported tool +measures the grouped layout only. diff --git a/benchmarks/tests/test_benchmark_smoke.py b/benchmarks/tests/test_benchmark_smoke.py index 5ebf3c47f25..fc17966ad1b 100644 --- a/benchmarks/tests/test_benchmark_smoke.py +++ b/benchmarks/tests/test_benchmark_smoke.py @@ -25,6 +25,7 @@ import sys import pytest +from Basilisk import getBuildInfo REPO_ROOT = Path(__file__).resolve().parents[2] @@ -224,7 +225,7 @@ def test_eigen_linear_algebra_benchmark_smoke(): "--target", target_name, "--config", - "Release", + getBuildInfo()["abi"]["build"]["configuration"], ] ) ) diff --git a/benchmarks/tests/test_fsw_startup_benchmark.py b/benchmarks/tests/test_fsw_startup_benchmark.py new file mode 100644 index 00000000000..78f207004e3 --- /dev/null +++ b/benchmarks/tests/test_fsw_startup_benchmark.py @@ -0,0 +1,189 @@ +# 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. + +"""Check the startup benchmark without rebuilding libraries during CI.""" + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from types import SimpleNamespace + +import pytest + + +REPOSITORY = Path(__file__).resolve().parents[2] +SCRIPT = REPOSITORY / "benchmarks/startup/benchmark_fsw_bindings.py" +SUBPROCESS_TIMEOUT_SECONDS = 120 # [s] + + +@pytest.fixture +def benchmark(): + """Load the standalone command without importing Basilisk or invoking a build.""" + spec = importlib.util.spec_from_file_location("fsw_startup_benchmark", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_fsw_startup_smoke(): + """Import one binding without changing the build cache, objects, or native files.""" + build = REPOSITORY / "dist3" + if not (build / "CMakeCache.txt").is_file() or not (build / "Basilisk/__init__.py").is_file(): + pytest.skip("The FSW startup smoke test requires a configured and built dist3 directory.") + paths = [build / "CMakeCache.txt", *build.glob("autoSource/*fsw*"), + *build.glob("Basilisk/fswAlgorithms/*"), + *build.glob("CMakeFiles/*mrpFeedback*.dir/**/*")] + before = {path: path.stat().st_mtime_ns for path in paths if path.is_file()} + result = subprocess.run( + [sys.executable, str(SCRIPT), "--build-dir", str(build), "--smoke"], + cwd=REPOSITORY, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SECONDS, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout)["seconds"] >= 0 + assert before == {path: path.stat().st_mtime_ns for path in before} + + +def test_cleanup_checks_all_paths_before_removing_files(benchmark, tmp_path): + """Reject an outside path before deleting any valid build output.""" + build = tmp_path / "build" + build.mkdir() + generated = build / "generated.so" + external = tmp_path / "external.so" + for path in (generated, external): + path.write_bytes(b"preserve") + with pytest.raises(ValueError, match="outside the build directory"): + benchmark.remove_outputs(build, [generated, external]) + assert generated.read_bytes() == external.read_bytes() == b"preserve" + + +def test_linker_output_selection(benchmark, tmp_path): + """Select native project link outputs across platforms, excluding dependencies.""" + listing = "\n".join([ + "Basilisk/fswAlgorithms/_mrpFeedback.so: CXX_MODULE_LIBRARY_LINKER__mrpFeedback_Release", + "Basilisk/fswAlgorithms/_mrpFeedback.pyd: CXX_MODULE_LIBRARY_LINKER__mrpFeedback_Release", + "Basilisk/fswAlgorithms/_mrpFeedback.lib: CXX_MODULE_LIBRARY_LINKER__mrpFeedback_Release", + "Basilisk/libBasilisk.dll: CXX_SHARED_LIBRARY_LINKER__Basilisk_Release", + "Basilisk/libBasilisk.dylib: CXX_SHARED_LIBRARY_LINKER__Basilisk_Release", + "external/dependency.so: CXX_SHARED_LIBRARY_LINKER__dependency_Release", + "Basilisk/_wrapper.so: CUSTOM_COMMAND", + ]) + assert {path.relative_to(tmp_path).as_posix() + for path in benchmark.linker_outputs(tmp_path, listing)} == { + "Basilisk/fswAlgorithms/_mrpFeedback.so", "Basilisk/fswAlgorithms/_mrpFeedback.pyd", + "Basilisk/libBasilisk.dll", "Basilisk/libBasilisk.dylib", + } + + +@pytest.mark.parametrize("collection_error", [False, True]) +def test_collection_profiler_preserves_errors_and_skips_execution(tmp_path, collection_error): + """Clear selected tests while keeping import errors visible in pytest's exit code.""" + test_file = tmp_path / "test_probe.py" + test_file.write_text( + "raise RuntimeError('collection failed')\n" if collection_error else + "def test_must_not_execute():\n raise AssertionError('test executed')\n", + encoding="utf-8", + ) + env = os.environ.copy() + env.update(PYTEST_DISABLE_PLUGIN_AUTOLOAD="1", PYTEST_ADDOPTS="", + PYTHONPATH=str(SCRIPT.parent), BSK_STARTUP_OUTPUT=str(tmp_path), + BSK_STARTUP_STARTED=str(time.time())) + env.pop("PYTEST_XDIST_WORKER", None) + result = subprocess.run( + [sys.executable, "-m", "pytest", "-p", "_collection_profile", str(test_file)], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SECONDS, + ) + assert result.returncode == (2 if collection_error else 5), result.stdout + result.stderr + profile = json.loads((tmp_path / "worker-controller.json").read_text(encoding="utf-8")) + assert profile["exit_code"] == result.returncode + assert profile["selected_items"] == (0 if collection_error else 1) + assert profile["ready_seconds"] >= 0 + + +def test_incremental_failure_restores_source_timestamp(benchmark, tmp_path, monkeypatch): + """A failed incremental build must leave the touched source's timestamp unchanged.""" + source = tmp_path / "fswAlgorithms/attControl/mrpFeedback/mrpFeedback.c" + source.parent.mkdir(parents=True) + source.write_text("/* Source remains unchanged. */\n", encoding="utf-8") + original = source.stat().st_mtime_ns + runner = benchmark.Benchmark.__new__(benchmark.Benchmark) + runner.source_dir = tmp_path + monkeypatch.setattr(runner, "native_outputs", lambda: [source]) + monkeypatch.setattr(benchmark, "MTIME_MARGIN_SECONDS", 0.0) # [s] + + def fail_build(label): + raise RuntimeError("simulated compiler failure") + + monkeypatch.setattr(runner, "build", fail_build) + with pytest.raises(RuntimeError, match="simulated compiler failure"): + runner.incremental("c", 0) + assert source.stat().st_mtime_ns == original + assert source.read_text(encoding="utf-8") == "/* Source remains unchanged. */\n" + + +@pytest.mark.parametrize("failure", [KeyboardInterrupt, RuntimeError]) +@pytest.mark.parametrize("recovery_fails", [False, True]) +def test_interrupted_full_relink_recovers_non_fsw_outputs(benchmark, tmp_path, monkeypatch, + failure, recovery_fails): + """Recover every deleted library and report an unsuccessful recovery honestly.""" + build = tmp_path / "build" + (build / "autoSource").mkdir(parents=True) + (build / "autoSource/fswCoreBindings.txt").write_text("mrpFeedback\n", encoding="utf-8") + (build / "CMakeCache.txt").write_text( + f"CMAKE_HOME_DIRECTORY:INTERNAL={REPOSITORY / 'src'}\n" + "CMAKE_GENERATOR:INTERNAL=Ninja\n" + "CMAKE_COMMAND:INTERNAL=cmake\n" + "CMAKE_MAKE_PROGRAM:FILEPATH=ninja\n", encoding="utf-8", + ) + suffix = ".pyd" if os.name == "nt" else ".so" + native_paths = [build / f"Basilisk/fswAlgorithms/_fswCoreNative{suffix}", + build / f"Basilisk/simulation/_spacecraft{suffix}"] + for path in native_paths: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"original native output") + runner = benchmark.Benchmark(SimpleNamespace( + build_dir=build, smoke=False, parallel=1, output=tmp_path / "results", + )) + monkeypatch.setattr(runner, "configure", lambda: None) + listing = "\n".join(f"{path.relative_to(build).as_posix()}: CXX_MODULE_LIBRARY_LINKER" + for path in native_paths) + monkeypatch.setattr(benchmark.subprocess, "check_output", lambda *args, **kwargs: listing) + + def interrupted_build(label, all_targets=False): + """Model a build's target selection after a failure immediately after deletion.""" + if label == "all-native-relink": + assert not any(path.exists() for path in native_paths) + raise failure("interrupted relink") + assert label == "restore" + if recovery_fails: + raise RuntimeError("recovery failed") + for path in native_paths if all_targets else native_paths[:1]: + path.write_bytes(b"rebuilt native output") + + monkeypatch.setattr(runner, "build", interrupted_build) + monkeypatch.setattr(runner, "measure_group", + lambda: runner.relink("all-native-relink", all_targets=True)) + expected_error = RuntimeError if recovery_fails else failure + expected_message = "recovery failed" if recovery_fails else "interrupted relink" + with pytest.raises(expected_error, match=expected_message): + runner.run() + saved = json.loads((runner.output / "results.json").read_text(encoding="utf-8")) + assert saved["restored_build"] is not recovery_fails + assert saved["error"] == "interrupted relink" + assert all(path.exists() for path in native_paths) is not recovery_fails diff --git a/docs/source/Support/Developer/CodingGuidlines.rst b/docs/source/Support/Developer/CodingGuidlines.rst index 23084533b7b..3b9b3c83a64 100644 --- a/docs/source/Support/Developer/CodingGuidlines.rst +++ b/docs/source/Support/Developer/CodingGuidlines.rst @@ -318,6 +318,13 @@ should pass. If not all Basilisk modules are built (i.e. the build process turned off the ``opNav`` option), then some Python tests will show up as skipped. +Native build regression tests use ``buildIntegration`` and ``ciSkip`` markers. +They run in a separate PR CI step on each platform; routine +``pytest -n auto -m "not ciSkip"`` runs retain the import and recorder checks. +To run the build tests locally from the repository root, use:: + + pytest -n 2 -m buildIntegration src/tests/test_fswBindingLayout.py src/tests/test_incremental_build_checks.py src/tests/test_cmake_message_cleanup.py + Guarding Tests for Optional Build Features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/Support/Developer/performanceBenchmarks.rst b/docs/source/Support/Developer/performanceBenchmarks.rst index 40945e06870..a70bc32eeb4 100644 --- a/docs/source/Support/Developer/performanceBenchmarks.rst +++ b/docs/source/Support/Developer/performanceBenchmarks.rst @@ -162,3 +162,40 @@ CI. New benchmarks should document: - Representative terminal output. - How to interpret each reported metric. - Any known limitations that affect comparison quality. + +Native Binding Startup Measurements +----------------------------------- + +The startup benchmark measures native binding builds, imports, incremental +compilation, and library sizes. Activate the Python environment for an existing +Ninja build, then run from the repository root: + +.. code-block:: bash + + python benchmarks/startup/benchmark_fsw_bindings.py --build-dir dist3 + +The default group is core FSW. Select ``--group simulation``, ``--group mujoco``, +or ``--group opnav`` for another group; optional features must be enabled in the +build. Add ``--pytest-workers 4`` to measure pytest collection with four workers. +Use ``--smoke`` to check one import without rebuilding. + +Full measurements rebuild generated bindings and temporarily change source +timestamps. Collection measurements also relink other Basilisk native libraries. +Keep other builds and tests stopped; on Windows, use a compiler developer shell. +On exit, the benchmark restores source timestamps and attempts to rebuild removed +outputs, including after interruption. JSON results and command logs are saved +under the build directory's ``benchmarks`` folder, or a new ``--output`` directory. + +Cold imports use the first process after rebuilding or relinking; warm imports +use the next fresh process. These measurements do not flush operating-system +caches. The current tool measures the grouped layout only. + +The :download:`macOS experiment report <../../../../benchmarks/startup/results/macos-arm64-2026-10-01.rst>` +and :download:`raw measurements <../../../../benchmarks/startup/results/macos-arm64-2026-10-01.json>` +compare separate and grouped FSW libraries in a development prototype. + +The :download:`simulation, MuJoCo, and OpenCV report <../../../../benchmarks/startup/results/macos-arm64-simulation-2026-10-01.rst>` +and its :download:`raw measurements <../../../../benchmarks/startup/results/macos-arm64-simulation-2026-10-01.json>` +cover the other groups. Both reports are archived experiments; their timings and +test counts describe those snapshots. Linux and Windows startup timings have +not been measured in these reports. diff --git a/docs/source/Support/bskKnownIssues.rst b/docs/source/Support/bskKnownIssues.rst index 4bc72056e78..a085b9d1da6 100644 --- a/docs/source/Support/bskKnownIssues.rst +++ b/docs/source/Support/bskKnownIssues.rst @@ -40,6 +40,33 @@ Version |release| for generator selection and the default and custom build-directory settings. Disabling export now removes an existing database from the selected build directory so clangd cannot keep reading stale compilation commands. +- Visual Studio 2026 solution-wide clean could fail because the C-message + target used an absolute path as its solution folder. It now uses a valid + relative folder name. + +- Ninja Multi-Config builds could fail on C message interface dependencies. + CMake now preserves these configured sources across native clean operations + and avoids dependencies on other build configurations. + +- ``scenarioSmallBodyNav`` supplied an 18-element state to the 12-state + :ref:`smallBodyNavEKF `, causing Debug builds to abort. The example now supplies + 12 elements. Invalid state and matrix dimensions raise ``BasiliskError`` in + Python during reset or update. + +- A buffer overflow in :ref:`headingSuKF ` covariance updates and invalid + zero-noise sampling in :ref:`hingedRigidBodyMotorSensor ` are fixed. Both + defects affected Debug and Release builds. + +- :ref:`horizonOpNav ` could use incorrect or uninitialized planet radii. A zero + limb-message planet ID now preserves ``planetTarget``; a supported nonzero ID + updates it before the calculation. Unsupported IDs raise ``BasiliskError``. + +- On macOS, validating many native extensions could delay the first Basilisk + import after a build. Message, core FSW, core simulation, MuJoCo, and OpenCV + bindings now use fewer libraries to reduce this overhead. Python imports and + recorder APIs are unchanged. Rebuilt libraries still require validation, so + first imports can remain slower than subsequent runs. + - GitHub issue 1587: The ``vscmg`` case in ``benchmarks/dynamics/benchmark_state_effectors.py`` failed during initialization with two or more components because of left-handed gimbal frames. The affected axes now form right-handed frames, fixing the benchmark in the current version. diff --git a/docs/source/Support/bskReleaseNotesSnippets/combined-native-bindings.rst b/docs/source/Support/bskReleaseNotesSnippets/combined-native-bindings.rst new file mode 100644 index 00000000000..79df41bb3d1 --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/combined-native-bindings.rst @@ -0,0 +1 @@ +- Reduced native binding startup overhead, especially after macOS rebuilds, by grouping message, core FSW, core simulation, MuJoCo, and OpenCV bindings into separate libraries. Python imports, message recorders, optional build features, and per-module compilation are preserved. diff --git a/docs/source/Support/bskReleaseNotesSnippets/constraint-parent-validation.rst b/docs/source/Support/bskReleaseNotesSnippets/constraint-parent-validation.rst new file mode 100644 index 00000000000..b0022f98999 --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/constraint-parent-validation.rst @@ -0,0 +1 @@ +- :ref:`constraintDynamicEffector ` now rejects unsupported parent types before computing forces and torques. diff --git a/docs/source/Support/bskReleaseNotesSnippets/native-module-fixes.rst b/docs/source/Support/bskReleaseNotesSnippets/native-module-fixes.rst new file mode 100644 index 00000000000..a250b15f3fc --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/native-module-fixes.rst @@ -0,0 +1,4 @@ +- Fixed a buffer overflow in :ref:`headingSuKF ` covariance updates. +- Corrected :ref:`horizonOpNav ` planet selection and added errors for unsupported planet IDs. +- Fixed zero-noise operation in :ref:`hingedRigidBodyMotorSensor `. +- Corrected the initial state dimension in ``scenarioSmallBodyNav``. Invalid state and covariance or noise matrix dimensions in :ref:`smallBodyNavEKF ` now raise ``BasiliskError`` in Python. diff --git a/docs/source/Support/bskReleaseNotesSnippets/ninja-multi-config-messages.rst b/docs/source/Support/bskReleaseNotesSnippets/ninja-multi-config-messages.rst new file mode 100644 index 00000000000..9150b202b9e --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/ninja-multi-config-messages.rst @@ -0,0 +1 @@ +- Fixed Ninja Multi-Config builds failing on C message interface dependencies and preserved configured C message sources across native clean operations. diff --git a/docs/source/Support/bskReleaseNotesSnippets/visual-studio-message-folder.rst b/docs/source/Support/bskReleaseNotesSnippets/visual-studio-message-folder.rst new file mode 100644 index 00000000000..993c38ecd8d --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/visual-studio-message-folder.rst @@ -0,0 +1 @@ +- Fixed Visual Studio 2026 clean failures caused by an absolute path in the C-message target's solution folder. diff --git a/examples/scenarioRoboticGrappling.py b/examples/scenarioRoboticGrappling.py index 0d3b4dcdb02..ac5ed3eb0d7 100644 --- a/examples/scenarioRoboticGrappling.py +++ b/examples/scenarioRoboticGrappling.py @@ -393,9 +393,10 @@ def computeDockingDistance(scSim): def logData(scSim): - scSim.datLog1 = scSim.scObject1.scStateOutMsg.recorder() - scSim.datLog2 = scSim.scObject2.scStateOutMsg.recorder() - scSim.armLog = scSim.translatingBody.translatingBodyOutMsg.recorder() + samplingTime = macros.sec2nano(0.1) # [ns] 10 Hz + scSim.datLog1 = scSim.scObject1.scStateOutMsg.recorder(samplingTime) + scSim.datLog2 = scSim.scObject2.scStateOutMsg.recorder(samplingTime) + scSim.armLog = scSim.translatingBody.translatingBodyOutMsg.recorder(samplingTime) scSim.AddModelToTask(scSim.simTaskName, scSim.datLog1) scSim.AddModelToTask(scSim.simTaskName, scSim.datLog2) scSim.AddModelToTask(scSim.simTaskName, scSim.armLog) diff --git a/examples/scenarioSmallBodyNav.py b/examples/scenarioSmallBodyNav.py index d9152d58a11..c2b4ca590c0 100644 --- a/examples/scenarioSmallBodyNav.py +++ b/examples/scenarioSmallBodyNav.py @@ -693,7 +693,7 @@ def run(show_plots): smallBodyNav.R = np.multiply(R, R).tolist() # Measurement Noise # Set the initial guess, x_0 - x_0 = np.zeros(18) + x_0 = np.zeros(12) x_0[0:3] = np.array([2458., -704.08, 844.275]) x_0[3:6] = np.array([1.475, -0.176, 0.894]) x_0[6:9] = np.array([-0.58, 0.615, 0.125]) diff --git a/pyproject.toml b/pyproject.toml index 768e925c170..a2a703218ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,4 +133,5 @@ markers = [ "scenarioTest: mark a test as a tutorial scenario test.", "ciSkip: mark a test that should be skipped on the CI test runs", "docsIntegration: documentation-tool integration test run explicitly by docs CI", + "buildIntegration: native build regression test run explicitly by platform CI", ] diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f919d83a8ca..72a9f532841 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -35,6 +35,8 @@ list(APPEND CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}/conan") include(bskTargetExcludeBuildOptions) include(bskBuildInfo) include(bskCollectWrapperCustomFiles) +include(bskCombineFswBindings) +include(bskCombineSimulationBindings) include(bskFindRustModules) include(bskSourceInventory) @@ -540,6 +542,40 @@ function(generate_package_targets TARGET_LIST LIB_DEP_LIST MODULE_DIR) list(FILTER WRAPPER_FILES EXCLUDE REGEX "\\.(c|cpp)$") list(APPEND WRAPPER_FILES ${WRAPPER_IMPLEMENTATION_FILES}) + set(MODULE_NAME "${TARGET_NAME}") + set(WRAPPER_LIBRARY_TYPE MODULE) + bsk_select_simulation_binding_group(BINDING_GROUP "${MODULE_NAME}" "${PARENT_DIR}" "${MODULE_DIR}") + if(BINDING_GROUP) + set(TARGET_NAME "${MODULE_NAME}GroupedObjects") + set(WRAPPER_LIBRARY_TYPE OBJECT) + set_property(SOURCE ${TARGET_FILE} APPEND PROPERTY SWIG_FLAGS "-interface" "_${MODULE_NAME}") + set_property(GLOBAL APPEND PROPERTY BSK_${BINDING_GROUP}_OBJECT_TARGETS "${TARGET_NAME}") + set_property(GLOBAL APPEND PROPERTY BSK_${BINDING_GROUP}_MODULES "${MODULE_DIR}.${MODULE_NAME}") + file(STRINGS "${CMAKE_SOURCE_DIR}/${TARGET_FILE}" SWIG_MODULE_LINE REGEX "^[ \t]*%module") + if(NOT SWIG_MODULE_LINE MATCHES "%module(\\([^)]*\\))?[ \t]+([A-Za-z_][A-Za-z_0-9]*)") + message(FATAL_ERROR "Cannot determine the Python module name in ${TARGET_FILE}") + endif() + set_property(GLOBAL APPEND PROPERTY BSK_${BINDING_GROUP}_PROXIES + "${MODULE_DIR}.${MODULE_NAME}=${MODULE_DIR}.${CMAKE_MATCH_2}") + elseif("${MODULE_DIR}" STREQUAL "fswAlgorithms") + bsk_collect_wrapper_custom_files( + FSW_CUSTOM_FILES "${PARENT_DIR}" "${MODULE_DIR}" "${CMAKE_SOURCE_DIR}" + "${EXTERNAL_MODULES_PATH}" "${BSK_CUSTOM_CMAKE_FILES}") + if(NOT FSW_CUSTOM_FILES) + set(TARGET_NAME "${MODULE_NAME}FswObjects") + set(WRAPPER_LIBRARY_TYPE OBJECT) + set(BINDING_GROUP fswCoreNative) + # Keep each Python extension's original name and initialization entry point. + set_property(SOURCE ${TARGET_FILE} APPEND PROPERTY SWIG_FLAGS "-interface" "_${MODULE_NAME}") + set_property(GLOBAL APPEND PROPERTY BSK_FSW_OBJECT_TARGETS "${TARGET_NAME}") + set_property(GLOBAL APPEND PROPERTY BSK_FSW_COMBINED_MODULES "${MODULE_NAME}") + endif() + set_property(GLOBAL APPEND PROPERTY BSK_FSW_BINDING_TARGETS "${TARGET_NAME}") + endif() + if(MODULE_DIR STREQUAL "simulation" OR MODULE_DIR STREQUAL "fswAlgorithms") + set_property(GLOBAL APPEND PROPERTY BSK_SIMULATION_BINDING_TARGETS "${TARGET_NAME}") + endif() + set_property(SOURCE ${TARGET_FILE} PROPERTY USE_TARGET_INCLUDE_DIRECTORIES TRUE) # Allows for include paths in the # .i files set_property(SOURCE ${TARGET_FILE} PROPERTY CPLUSPLUS ON) @@ -547,7 +583,7 @@ function(generate_package_targets TARGET_LIST LIB_DEP_LIST MODULE_DIR) swig_add_library( ${TARGET_NAME} LANGUAGE "python" - TYPE MODULE + TYPE ${WRAPPER_LIBRARY_TYPE} SOURCES ${TARGET_FILE} ${WRAPPER_FILES} # TODO: Following commented lines will become default with future imports OUTFILE_DIR @@ -559,7 +595,30 @@ function(generate_package_targets TARGET_LIST LIB_DEP_LIST MODULE_DIR) "${CMAKE_BINARY_DIR}/Basilisk/${MODULE_DIR}") # generated .py file bsk_suppress_strict_warnings_for_sources( - "${CMAKE_BINARY_DIR}/Basilisk/${MODULE_DIR}/${TARGET_NAME}PYTHON_wrap.cxx") + "${CMAKE_BINARY_DIR}/Basilisk/${MODULE_DIR}/${MODULE_NAME}PYTHON_wrap.cxx") + + if(WRAPPER_LIBRARY_TYPE STREQUAL "OBJECT") + set_target_properties(${TARGET_NAME} PROPERTIES POSITION_INDEPENDENT_CODE ON) + if(BINDING_GROUP STREQUAL "mujocoNative") + # Retain shared director RTTI and callbacks while making SWIG's + # per-wrapper external mutex definitions one C++17 inline variable. + set(SWIG_WRAPPER "${CMAKE_BINARY_DIR}/Basilisk/${MODULE_DIR}/${MODULE_NAME}PYTHON_wrap.cxx") + set(GROUPED_WRAPPER "${CMAKE_BINARY_DIR}/Basilisk/${MODULE_DIR}/${MODULE_NAME}PYTHON_grouped.cxx") + add_custom_command(OUTPUT "${GROUPED_WRAPPER}" + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/cmake/prepareGroupedWrapper.py" + "${SWIG_WRAPPER}" "${GROUPED_WRAPPER}" + DEPENDS "${SWIG_WRAPPER}" "${CMAKE_SOURCE_DIR}/cmake/prepareGroupedWrapper.py" + COMMENT "Preparing ${MODULE_NAME} for the MuJoCo binding group" VERBATIM) + get_target_property(WRAPPER_SOURCES ${TARGET_NAME} SOURCES) + list(REMOVE_ITEM WRAPPER_SOURCES "${SWIG_WRAPPER}") + set_property(TARGET ${TARGET_NAME} PROPERTY SOURCES ${WRAPPER_SOURCES} "${GROUPED_WRAPPER}") + bsk_suppress_strict_warnings_for_sources("${GROUPED_WRAPPER}") + endif() + bsk_isolate_fsw_symbols("${MODULE_NAME}" ${WRAPPER_IMPLEMENTATION_FILES} + "${CMAKE_BINARY_DIR}/Basilisk/${MODULE_DIR}/${MODULE_NAME}PYTHON_wrap.cxx") + add_custom_target(${MODULE_NAME}) + add_dependencies(${MODULE_NAME} ${BINDING_GROUP}) + endif() if("${MODULE_DIR}" STREQUAL "ExternalModules") bsk_suppress_strict_warnings_for_sources(${C_FILES}) @@ -1000,6 +1059,9 @@ generate_package_targets("${ORBIT_CONTROL_TARGETS}" "${ARCHITECTURE_LIBS};" "fsw generate_package_targets("${TRANS_DET_TARGETS}" "transDeterminationLib;ModuleIdGenerator" "fswAlgorithms") generate_package_targets("${VEH_CONFIG_TARGETS}" "${ARCHITECTURE_LIBS};" "fswAlgorithms") +bsk_finalize_fsw_bindings() +bsk_finalize_simulation_bindings() + # Rust modules under fswAlgorithms/ -- see the "ARCHITECTURE" section above. find_rust_package_targets("${CMAKE_SOURCE_DIR}/fswAlgorithms" FSW_RUST_TARGETS) generate_rust_package_targets("${FSW_RUST_TARGETS}" "${ARCHITECTURE_LIBS};" "fswAlgorithms") @@ -1061,6 +1123,12 @@ file( "${CMAKE_BINARY_DIR}/Basilisk/*") foreach(DIR ${DIST_DIRECTORIES}) + # Messaging owns its initializer through a build-time generator. An empty + # configure-time placeholder would make that output appear up to date after + # cleaning and reconfiguring an existing build directory. + if(DIR STREQUAL "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging") + continue() + endif() if(IS_DIRECTORY ${DIR} AND NOT IS_SYMLINK ${DIR}) file( GLOB DIST_DIR_FILES diff --git a/src/architecture/_GeneralModuleFiles/swig_eigen.i b/src/architecture/_GeneralModuleFiles/swig_eigen.i index ecfbe7b989c..98e53d97a56 100644 --- a/src/architecture/_GeneralModuleFiles/swig_eigen.i +++ b/src/architecture/_GeneralModuleFiles/swig_eigen.i @@ -202,7 +202,7 @@ Otherwise, the number of rows and columns are returned in the pair. The number of columns are obtained from the first element of the input sequence. (i.e. ragged nested sequences are not checked) */ -std::optional> getInputSize(PyObject *input) +static std::optional> getInputSize(PyObject *input) { // Vectors and matrices must come from python sequences if(!PySequence_Check(input)) { @@ -412,13 +412,13 @@ void fillPyObjList(PyObject *input, const T& value) // Eigen::MRPd and Eigen::Quaterniond need to be converted first to Eigen::Matrix template<> -void fillPyObjList(PyObject *input, const Eigen::MRPd& value) +inline void fillPyObjList(PyObject *input, const Eigen::MRPd& value) { - return fillPyObjList(input, value.vec()); + return fillPyObjList(input, value.coeffs()); } template<> -void fillPyObjList(PyObject *input, const Eigen::Quaterniond& value) +inline void fillPyObjList(PyObject *input, const Eigen::Quaterniond& value) { return fillPyObjList(input, {value.w(), value.x(), value.y(), value.z()}); } diff --git a/src/architecture/messaging/CMakeLists.txt b/src/architecture/messaging/CMakeLists.txt index b10a511a18e..fa0e5aa467e 100644 --- a/src/architecture/messaging/CMakeLists.txt +++ b/src/architecture/messaging/CMakeLists.txt @@ -72,7 +72,7 @@ generate_payload_manifest( "${MESSAGE_PAYLOAD_MANIFEST}" ${MESSAGE_PAYLOAD_FILES}) -function(generate_messages searchDir generateCCode messageFilesVariable) +function(generate_messages searchDir generateCCode messageFilesVariable outputVariable) file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/autoSource/cMsgMeta) @@ -83,6 +83,7 @@ function(generate_messages searchDir generateCCode messageFilesVariable) set(CLANG_LANG_FLAG "c++") endif() + set(message_interfaces) foreach(msgFile IN LISTS ${messageFilesVariable}) get_filename_component(TARGET_NAME ${msgFile} NAME_WE) @@ -168,35 +169,15 @@ function(generate_messages searchDir generateCCode messageFilesVariable) include_directories("${CMAKE_CURRENT_SOURCE_DIR}/../../") include_directories(${NUMPY_INCLUDE_DIR}) - swig_add_library( - ${TARGET_NAME} - LANGUAGE "python" - TYPE MODULE - SOURCES ${COMP_OUT_NAME} OUTFILE_DIR "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/" - OUTPUT_DIR "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/") - - bsk_suppress_strict_warnings_for_sources( - "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/${TARGET_NAME}PYTHON_wrap.cxx") - - set_property(TARGET ${TARGET_NAME} APPEND PROPERTY SWIG_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/newMessaging.ih" - "${CMAKE_CURRENT_SOURCE_DIR}/messaging.h" - "${CMAKE_CURRENT_SOURCE_DIR}/messagingBase.h" - "${CMAKE_CURRENT_SOURCE_DIR}/messagingBase.i" - "${CMAKE_CURRENT_SOURCE_DIR}/messagingSupport.i" - "${CMAKE_CURRENT_SOURCE_DIR}/messagingSupportTypemaps.swg") - add_dependencies(${TARGET_NAME} messagingBase messagingSupport swigtrick) + list(APPEND message_interfaces "${COMP_OUT_NAME}") + + # Preserve the existing convenience build targets. Every message is now + # delivered by the same library, with a separate generated wrapper object. + add_custom_target(${TARGET_NAME}) + add_dependencies(${TARGET_NAME} messagingNative) set_target_properties(${TARGET_NAME} PROPERTIES FOLDER "architecture/messaging/derivedCode") - set_target_properties(${SWIG_MODULE_${TARGET_NAME}_REAL_NAME} - PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging") - set_target_properties( - ${SWIG_MODULE_${TARGET_NAME}_REAL_NAME} PROPERTIES LIBRARY_OUTPUT_DIRECTORY_DEBUG - "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging") - set_target_properties( - ${SWIG_MODULE_${TARGET_NAME}_REAL_NAME} PROPERTIES LIBRARY_OUTPUT_DIRECTORY_RELEASE - "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging") - target_link_libraries(${SWIG_MODULE_${TARGET_NAME}_REAL_NAME} PUBLIC architectureLib) endforeach() + set(${outputVariable} ${message_interfaces} PARENT_SCOPE) endfunction(generate_messages) # Remove the retired compatibility package from incremental builds as well. @@ -216,10 +197,28 @@ add_custom_command( VERBATIM) add_custom_target(msgKeepAlivePy DEPENDS ${MSG_KEEP_ALIVE_PY}) +set(MSG_LOADER_PY "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/_load_messages.py") +add_custom_command( + OUTPUT ${MSG_LOADER_PY} + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/_load_messages.py" "${MSG_LOADER_PY}" + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/_load_messages.py + VERBATIM) +add_custom_target(msgLoaderPy DEPENDS ${MSG_LOADER_PY}) + # Generate package imports from the exact payload inventory used for SWIG # targets. This avoids a second directory scan with different file matching. +set(MSG_PACKAGE_OUTPUTS + "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/__init__.py" + "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/_messagingSupport.py") +foreach(MSG_PAYLOAD IN LISTS MESSAGE_PAYLOAD_FILES) + get_filename_component(MSG_NAME "${MSG_PAYLOAD}" NAME_WE) + list(APPEND MSG_PACKAGE_OUTPUTS + "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/_${MSG_NAME}.py") +endforeach() add_custom_command( - OUTPUT ${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/__init__.py + OUTPUT ${MSG_PACKAGE_OUTPUTS} COMMAND ${Python3_EXECUTABLE} generatePackageInit.py "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/" @@ -230,11 +229,12 @@ add_custom_command( ${MESSAGE_PAYLOAD_MANIFEST} ${MESSAGE_PAYLOAD_FILES} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/msgAutoSource + COMMENT "Updating the messaging package and private import shims" VERBATIM) # Custom target for establishing dependency -add_custom_target(swigtrick DEPENDS ${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/__init__.py) -add_dependencies(swigtrick msgKeepAlivePy) +add_custom_target(swigtrick DEPENDS ${MSG_PACKAGE_OUTPUTS}) +add_dependencies(swigtrick msgKeepAlivePy msgLoaderPy) # Build common payload-wrapper types and helpers once, inside the messaging # package, instead of emitting them into every generated payload extension. @@ -250,30 +250,48 @@ set_property( "-I${Python3_INCLUDE_DIRS}" "-I${NUMPY_INCLUDE_DIR}") +# SWIG generates one source and Python proxy per interface. Keeping those +# translation units separate preserves parallel and incremental compilation; +# only the final native library is shared. With multiple interfaces UseSWIG +# retains each interface's own initialization entry point and proxy imports. +generate_messages(msgPayloadDefCpp "False" CPP_MESSAGE_PAYLOAD_FILES CPP_MESSAGE_INTERFACES) +generate_messages(msgPayloadDefC "True" C_MESSAGE_PAYLOAD_FILES C_MESSAGE_INTERFACES) swig_add_library( - messagingSupport + messagingNative LANGUAGE "python" TYPE MODULE - SOURCES ${MESSAGING_SUPPORT_INTERFACE} + SOURCES ${MESSAGING_SUPPORT_INTERFACE} ${CPP_MESSAGE_INTERFACES} ${C_MESSAGE_INTERFACES} OUTFILE_DIR "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/" OUTPUT_DIR "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/") set_property( - TARGET messagingSupport + TARGET messagingNative APPEND - PROPERTY SWIG_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/messagingSupportTypemaps.swg") - -bsk_suppress_strict_warnings_for_sources( - "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/messagingSupportPYTHON_wrap.cxx") -target_include_directories(messagingSupport PRIVATE ${Python3_INCLUDE_DIRS}) -target_include_directories(messagingSupport PRIVATE ${NUMPY_INCLUDE_DIR}) -target_include_directories(messagingSupport PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../..) -target_link_libraries(messagingSupport PUBLIC architectureLib) -set_target_properties(messagingSupport PROPERTIES FOLDER "architecture/messaging") + PROPERTY SWIG_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/newMessaging.ih" + "${CMAKE_CURRENT_SOURCE_DIR}/messaging.h" + "${CMAKE_CURRENT_SOURCE_DIR}/messagingBase.h" + "${CMAKE_CURRENT_SOURCE_DIR}/messagingBase.i" + "${CMAKE_CURRENT_SOURCE_DIR}/messagingSupport.i" + "${CMAKE_CURRENT_SOURCE_DIR}/messagingSupportTypemaps.swg") + +foreach(message_interface IN ITEMS ${MESSAGING_SUPPORT_INTERFACE} ${CPP_MESSAGE_INTERFACES} ${C_MESSAGE_INTERFACES}) + get_filename_component(message_name "${message_interface}" NAME_WE) + bsk_suppress_strict_warnings_for_sources( + "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging/${message_name}PYTHON_wrap.cxx") +endforeach() +target_include_directories(messagingNative PRIVATE ${Python3_INCLUDE_DIRS} ${NUMPY_INCLUDE_DIR}) +target_include_directories(messagingNative PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../..) +target_link_libraries(messagingNative PRIVATE architectureLib ${PYTHON3_MODULE}) +add_dependencies(messagingNative messagingBase swigtrick) +set_target_properties(messagingNative PROPERTIES FOLDER "architecture/messaging") set_target_properties( - ${SWIG_MODULE_messagingSupport_REAL_NAME} + messagingNative PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging" LIBRARY_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging" - LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging") + LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging" + LIBRARY_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging" + LIBRARY_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/Basilisk/architecture/messaging") +add_custom_target(messagingSupport DEPENDS messagingNative) # The target is created above with messaging-package output paths; do not let # the root architecture target discovery create it a second time. @@ -282,11 +300,6 @@ set(EXCLUDED_BSK_TARGETS ${EXCLUDED_BSK_TARGETS} PARENT_SCOPE) -# Generate per-payload equality headers. Each "_equality.h" is included -# only by that payload's own SWIG module. -generate_messages(msgPayloadDefCpp "False" CPP_MESSAGE_PAYLOAD_FILES) -generate_messages(msgPayloadDefC "True" C_MESSAGE_PAYLOAD_FILES) - set(recorder_copy_contract_test_source "${CMAKE_CURRENT_SOURCE_DIR}/_UnitTest/test_recorder_copy_contract.cpp") if(BUILD_TESTING AND EXISTS "${recorder_copy_contract_test_source}") diff --git a/src/architecture/messaging/_UnitTest/test_combinedMessageBindings.py b/src/architecture/messaging/_UnitTest/test_combinedMessageBindings.py new file mode 100644 index 00000000000..f970185a631 --- /dev/null +++ b/src/architecture/messaging/_UnitTest/test_combinedMessageBindings.py @@ -0,0 +1,268 @@ +# 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. + +"""Check native message loading without changing the public Python interfaces.""" + +import importlib +import importlib.machinery +from pathlib import Path +import pkgutil +import subprocess +import sys +import textwrap + +import pytest + +from Basilisk.architecture import messaging + + +def test_message_bindings_share_one_native_file(): + """All payload modules retain their identities and share one native file.""" + module_names = { + entry.name + for entry in pkgutil.iter_modules(messaging.__path__) + if entry.name.endswith("Payload") and not entry.name.startswith("_") + } + assert "SCStatesMsgPayload" in module_names + module_names.add("messagingSupport") + native_paths = set() + for name in module_names: + wrapper = importlib.import_module(f"{messaging.__name__}.{name}") + full_name = f"{messaging.__name__}._{name}" + native = importlib.import_module(full_name) + assert native.__spec__.name == full_name + assert getattr(wrapper, f"_{name}") is native + assert getattr(messaging, f"_{name}") is native + original_spec = native.__spec__ + assert importlib.reload(native) is native + assert native.__spec__ is original_spec + assert native.__file__ == original_spec.origin + native_paths.add(Path(native.__file__).resolve()) + + assert len(native_paths) == 1 + packaged_binaries = { + path.resolve() + for directory in messaging.__path__ + for path in Path(directory).iterdir() + if any(path.name.endswith(suffix) for suffix in importlib.machinery.EXTENSION_SUFFIXES) + } + assert packaged_binaries == native_paths + + +def test_recorders_work_before_explicit_messaging_import(): + """A fresh process registers module-output recorders and preserves imports.""" + code = textwrap.dedent("""\ + import importlib + import importlib.machinery + + def check_shim_loading(loader, module): + assert not (loader.name.startswith('Basilisk.architecture.messaging._') + and loader.name.endswith(('Payload', '_messagingSupport'))), loader.name + return original_exec(loader, module) + + original_exec = importlib.machinery.SourceFileLoader.exec_module + importlib.machinery.SourceFileLoader.exec_module = check_shim_loading + from Basilisk.simulation import spacecraft + importlib.machinery.SourceFileLoader.exec_module = original_exec + sc = spacecraft.Spacecraft() + recorder = sc.scStateOutMsg.recorder() + + from Basilisk.architecture import messaging + assert isinstance(recorder, messaging.SCStatesMsgRecorder) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = [1.0, 2.0, 3.0] # [m] + write_time = 42 # [ns] + sc.scStateOutMsg.write(payload, write_time) + recorder.UpdateState(write_time) + assert recorder.times().tolist() == [write_time] + assert recorder.r_BN_N.tolist() == [[1.0, 2.0, 3.0]] # [m] + + native = importlib.import_module('Basilisk.architecture.messaging._SCStatesMsgPayload') + importlib.reload(messaging) + assert importlib.import_module(native.__name__) is native + assert isinstance(sc.scStateOutMsg.recorder(), messaging.SCStatesMsgRecorder) + """) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("first_import", ["package", "private"]) +@pytest.mark.parametrize("second_import", ["private", "other_private", "package"]) +@pytest.mark.parametrize("fail_first", [False, True], ids=["success", "retry"]) +def test_concurrent_message_imports_wait_for_native_initialization( + tmp_path, first_import, second_import, fail_first): + """Wait for native initialization and recover from a failed first import. + + :param tmp_path: Directory for an isolated package used in failure-injection cases. + :param first_import: Start by importing the messaging package or its private extension. + :param second_import: Import the same binding, another binding, or the messaging package concurrently. + :param fail_first: Inject an initialization failure before testing a clean retry. + """ + package_name = messaging.__name__ + if fail_first: + # Keep injected failures inside a small package using the real loader + # and native bindings. Basilisk's top-level initializer imports messaging + # eagerly, so failing it also invalidates unrelated ancestor packages. + package_name = "messageImportProbe" + package = tmp_path / package_name + package.mkdir() + (package / "__init__.py").write_text( + f"__path__.append({str(Path(messaging.__file__).parent)!r})\n" + "from ._load_messages import load_message_module\n" + "load_message_module('messagingSupport')\n" + "load_message_module('AttRefMsgPayload')\n" + "load_message_module('SCStatesMsgPayload')\n", + encoding="utf-8", + ) + code = textwrap.dedent("""\ + import importlib + import importlib.machinery + import os + import sys + import threading + + package_name = sys.argv[4] + sys.path.insert(0, sys.argv[5]) + native_name = package_name + '._AttRefMsgPayload' + public_name = package_name + '.AttRefMsgPayload' + first_name = package_name if sys.argv[1] == 'package' else native_name + second_name = { + 'private': native_name, + 'other_private': package_name + '._SCStatesMsgPayload', + 'package': package_name, + }[sys.argv[2]] + fail_first = sys.argv[3] == 'True' + if fail_first and os.name == 'nt': + # The probe bypasses Basilisk.__init__, including its DLL setup. + # Retain the directory handle across failed imports and retries. + dll_directory = os.add_dll_directory(sys.argv[6]) + timeout = 10.0 # [s] + blocking_window = 0.2 # [s] + first_ready = threading.Event() + release = threading.Event() + second_started = threading.Event() + second_done = threading.Event() + modules = {} + errors = {} + attempts = [] + original_exec = importlib.machinery.ExtensionFileLoader.exec_module + + def paused_exec(loader, module): + if loader.name == native_name: + attempts.append(module) + if len(attempts) == 1: + first_ready.set() + assert release.wait(timeout), 'Native initialization was never released' + if fail_first: + raise RuntimeError('Injected native initialization failure') + return original_exec(loader, module) + + def import_module(label, name): + if label == 'second': + second_started.set() + try: + modules[label] = importlib.import_module(name) + except BaseException as error: + errors[label] = error + finally: + if label == 'first': + first_ready.set() + else: + second_done.set() + + importlib.machinery.ExtensionFileLoader.exec_module = paused_exec + first = threading.Thread(target=import_module, args=('first', first_name), daemon=True) + second = threading.Thread(target=import_module, args=('second', second_name), daemon=True) + try: + first.start() + assert first_ready.wait(timeout), 'Native initialization did not start' + if 'first' in errors: + raise errors['first'] + second.start() + assert second_started.wait(timeout), 'Concurrent import did not start' + returned_early = second_done.wait(blocking_window) + finally: + release.set() + first.join(timeout) + if second.ident is not None: + second.join(timeout) + importlib.machinery.ExtensionFileLoader.exec_module = original_exec + + assert not first.is_alive() and not second.is_alive(), 'Import deadlock' + assert not returned_early, 'Concurrent import returned an unfinished native module' + assert second_done.is_set() + if fail_first: + assert isinstance(errors.get('first'), RuntimeError), errors + assert str(errors['first']) == 'Injected native initialization failure' + if 'second' in errors: + # A waiting import may fail with the first package import on + # some Python versions. Its next attempt must initialize cleanly. + assert isinstance(errors['second'], ImportError), errors + assert len(attempts) == 1 + assert native_name not in sys.modules + assert public_name not in sys.modules + importlib.machinery.ExtensionFileLoader.exec_module = paused_exec + try: + modules['second'] = importlib.import_module(second_name) + finally: + importlib.machinery.ExtensionFileLoader.exec_module = original_exec + del errors['second'] + assert set(errors) == {'first'}, errors + assert len(attempts) == 2 + else: + assert not errors, errors + assert len(attempts) == 1 + + native = importlib.import_module(native_name) + messaging = importlib.import_module(package_name) + assert modules['second'] is importlib.import_module(second_name) + assert native is attempts[-1] + assert messaging._AttRefMsgPayload is native + assert all(not getattr(module.__spec__, '_initializing', False) for module in attempts) + # A failed package import can leave earlier native bindings cached. + # Retrying must also attach those bindings to the new package object. + for name, module in tuple(sys.modules.items()): + if name.startswith(package_name + '._') and isinstance( + module.__loader__, importlib.machinery.ExtensionFileLoader): + assert getattr(messaging, name.rsplit('.', 1)[1]) is module + + if fail_first: + payload = native.new_AttRefMsgPayload() + try: + assert native.AttRefMsgPayload_sigma_RN_get(payload) == [0.0, 0.0, 0.0] + finally: + native.delete_AttRefMsgPayload(payload) + payload.own(False) + else: + proxy = importlib.import_module(public_name) + assert proxy._AttRefMsgPayload is native + payload = proxy.AttRefMsgPayload() + payload.sigma_RN = [0.1, 0.2, 0.3] # [-] MRP attitude + assert native.AttRefMsgPayload_sigma_RN_get(payload) == [0.1, 0.2, 0.3] + message = messaging.AttRefMsg().write(payload) + recorder = message.recorder() + recorder.UpdateState(0) # [ns] + assert recorder.sigma_RN.tolist() == [[0.1, 0.2, 0.3]] + importlib.reload(messaging) + assert importlib.import_module(native_name) is native + """) + subprocess_timeout = 60 # [s] + result = subprocess.run( + [sys.executable, "-c", code, first_import, second_import, + str(fail_first), package_name, str(tmp_path), str(Path(messaging.__file__).resolve().parents[2])], + capture_output=True, text=True, timeout=subprocess_timeout, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/src/architecture/messaging/_UnitTest/test_generateSWIGModules.py b/src/architecture/messaging/_UnitTest/test_generateSWIGModules.py index 316d26006f0..0d9de020e61 100644 --- a/src/architecture/messaging/_UnitTest/test_generateSWIGModules.py +++ b/src/architecture/messaging/_UnitTest/test_generateSWIGModules.py @@ -98,10 +98,15 @@ def test_package_init_uses_only_cmake_manifest_entries(tmp_path): ) previous_manifest_path = tmp_path / "messagePayloads.previous.txt" expected = """\ +from ._load_messages import load_message_module as _load_message_module +_load_message_module('messagingSupport') from Basilisk.architecture.messaging.messagingSupport import * +_load_message_module('AlphaMsgPayload') from Basilisk.architecture.messaging.AlphaMsgPayload import * +_load_message_module('ZuluMsgPayload') from Basilisk.architecture.messaging.ZuluMsgPayload import * from Basilisk.architecture.messagingBase import * +del _load_message_module """ output_path = tmp_path / "output" @@ -117,6 +122,16 @@ def test_package_init_uses_only_cmake_manifest_entries(tmp_path): assert previous_manifest_path.read_text(encoding="utf-8") == ( "AlphaMsgPayload\nZuluMsgPayload\n" ) + shims = [output_path / f"_{name}.py" for name in ( + "messagingSupport", "AlphaMsgPayload", "ZuluMsgPayload", + )] + assert all(path.is_file() for path in shims) + original_times = {path: path.stat().st_mtime_ns for path in shims} + generatePackageInit.generate_package_init(output_path, manifest_path, previous_manifest_path) + assert {path: path.stat().st_mtime_ns for path in shims} == original_times + shims[-1].unlink() + generatePackageInit.generate_package_init(output_path, manifest_path, previous_manifest_path) + assert shims[-1].is_file() def test_package_init_rejects_duplicate_manifest_entries(tmp_path): @@ -135,6 +150,31 @@ def test_package_init_rejects_duplicate_manifest_entries(tmp_path): ) +def test_package_init_retires_separate_native_libraries(tmp_path): + """An incremental upgrade removes old binaries while retaining wrappers.""" + output_path = tmp_path / "messaging" + output_path.mkdir() + manifest_path = tmp_path / "messagePayloads.txt" + manifest_path.write_text("SCStatesMsgPayload\n", encoding="utf-8") + legacy_binaries = [ + output_path / f"_{name}{suffix}" + for name in ("SCStatesMsgPayload", "messagingSupport") + for suffix in generatePackageInit.COMPILED_EXTENSION_SUFFIXES + ] + retained = [output_path / name for name in ( + "SCStatesMsgPayload.py", "_messagingNative.so", "_messagingNative.pyd", + "_load_messages.py", "_UserExtension.so", + )] + for path in [*legacy_binaries, *retained]: + path.write_text("fixture", encoding="utf-8") + + generatePackageInit.generate_package_init( + output_path, manifest_path, tmp_path / "messagePayloads.previous.txt", + ) + assert all(not path.exists() for path in legacy_binaries) + assert all(path.read_text(encoding="utf-8") == "fixture" for path in retained) + + def test_package_init_rejects_invalid_manifest_entry(tmp_path): """Only payload module names can be emitted as package imports.""" manifest_path = tmp_path / "messagePayloads.txt" @@ -172,6 +212,7 @@ def test_package_init_removes_only_stale_payload_artifacts(tmp_path): )) required_artifacts = { module_output_path / f"{removed_payload}.py", + module_output_path / f"_{removed_payload}.py", module_output_path / f"{removed_payload}PYTHON_wrap.cxx", module_output_path / f"_{removed_payload}.so", module_output_path / f"_{removed_payload}.pyd", diff --git a/src/architecture/messaging/_UnitTest/test_outputMessageOwnership.cpp b/src/architecture/messaging/_UnitTest/test_outputMessageOwnership.cpp index 72014e0e886..35c1db0aa6a 100644 --- a/src/architecture/messaging/_UnitTest/test_outputMessageOwnership.cpp +++ b/src/architecture/messaging/_UnitTest/test_outputMessageOwnership.cpp @@ -227,6 +227,12 @@ template void expectCleanupAfterAllocationFailure(const Factory& factory) { +#if defined(_MSC_VER) && _ITERATOR_DEBUG_LEVEL != 0 + // MSVC's debug STL allocates iterator proxies inside noexcept container + // constructors. A global-new failure there terminates instead of unwinding. + // Release runs these sweeps; ordinary ownership checks still run in Debug. + GTEST_SKIP() << "Global allocation-failure sweeps cannot unwind MSVC debug iterator-proxy construction"; +#endif factory(); // Fail each allocation in turn, including those between paired output vectors. for (int failAfter = 0; failAfter < 256; ++failAfter) { diff --git a/src/architecture/messaging/_load_messages.py b/src/architecture/messaging/_load_messages.py new file mode 100644 index 00000000000..d36d7aa5bd5 --- /dev/null +++ b/src/architecture/messaging/_load_messages.py @@ -0,0 +1,72 @@ +# 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. + +"""Load the message bindings' separate entry points from one native library.""" + +import importlib.util as _import_util +import sys as _sys + + +# Resolve the platform's extension suffix and the installed package location +# without importing the container: its entry points retain the SWIG names. +_library_spec = _import_util.find_spec(f"{__package__}._messagingNative") +if _library_spec is None or _library_spec.origin is None: + raise ImportError(f"The combined messaging library is missing from {__package__}.") +_library_path = _library_spec.origin + + +def load_message_module(module_name: str) -> None: + """Initialize a native binding before importing its Python proxy. + + Preserve the original module names and normal module caching so direct + imports, SWIG class registration, and recorder creation keep working. + The generated package initializer holds Python's package import lock. + Cache native modules only after initialization so concurrent direct imports + wait for that initializer instead of receiving an unfinished binding. + Failed initialization leaves no cached module. + No process-wide import hook is installed. + + :param module_name: SWIG module name without its leading underscore. + :raises ImportError: If the native binding cannot be loaded. + """ + native_name = f"_{module_name}" + full_name = f"{__package__}.{native_name}" + module = _sys.modules.get(full_name) + # A fallback shim can occupy this name while waiting for the package. + # Reuse only native bindings whose initialization has completed. + native_spec = getattr(module, "_bsk_message_native_spec", None) + if native_spec is not None: + # reload() can execute the fallback shim on the existing native module. + # Restore its metadata without initializing the extension again. + module.__spec__ = native_spec + module.__loader__ = native_spec.loader + module.__file__ = native_spec.origin + module.__package__ = native_spec.parent + module.__dict__.pop("__cached__", None) + else: + spec = _import_util.spec_from_file_location(full_name, _library_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load {full_name} from {_library_path}.") + module = _import_util.module_from_spec(spec) + # SWIG initializes its native type information during exec_module. + # Publishing earlier lets direct imports bypass the package import lock + # and construct payloads whose type information is still uninitialized. + spec.loader.exec_module(module) + module._bsk_message_native_spec = spec + _sys.modules[full_name] = module + # A failed package import can leave earlier bindings cached. Attach them + # to the replacement package as well when its initializer is retried. + setattr(_sys.modules[__package__], native_name, module) diff --git a/src/architecture/messaging/cMsgCInterface/CMakeLists.txt b/src/architecture/messaging/cMsgCInterface/CMakeLists.txt index 81228ec986b..de882fcb34c 100644 --- a/src/architecture/messaging/cMsgCInterface/CMakeLists.txt +++ b/src/architecture/messaging/cMsgCInterface/CMakeLists.txt @@ -51,9 +51,10 @@ function(GenCMessages TARGET searchDir) configure_file(msg_C.h.in ${headerFile} @ONLY) configure_file(msg_C.cpp.in ${objectFile} @ONLY) - # Mark these files as GENERATED + # configure_file owns these outputs. Marking them GENERATED without a + # build-time rule creates invalid cross-configuration dependencies in + # Ninja Multi-Config and lets native clean remove required sources. set(generatedFiles ${objectFile} ${headerFile}) - set_source_files_properties(${generatedFiles} PROPERTIES GENERATED ON) # Add these files as sources for the TARGET target_sources(${TARGET} PRIVATE ${generatedFiles}) @@ -62,14 +63,6 @@ function(GenCMessages TARGET searchDir) endforeach() endfunction(GenCMessages) -SET(LIB_DIR "${CMAKE_SOURCE_DIR}/simulation/architecture/messaging/cMsgCInterface/") - -# Determine the name of the parent directory of _GeneralModuleFiles (i.e. dynamics, environment, etc) -get_filename_component(PARENT_DIR ${LIB_DIR} DIRECTORY) # Relative to the source directory (e.g. simulation/power) -string(FIND ${PARENT_DIR} "/" DIR_NAME_IDX REVERSE) -math(EXPR DIR_NAME_IDX "${DIR_NAME_IDX} + 1")# "simulation/" -string(SUBSTRING ${PARENT_DIR} ${DIR_NAME_IDX} -1 PARENT_DIR_NAME) # Should be "power" - # Set the library name (ex. dynamicsLib, environmentLib, etc) set(LIB_NAME "cMsgCInterface") @@ -107,7 +100,8 @@ target_link_libraries(${LIB_NAME} ${PYTHON3_MODULE}) #target_link_libraries(${LIB_NAME} Eigen3::Eigen3) # define build location, IDE generation specifications -set_target_properties(${LIB_NAME} PROPERTIES FOLDER ${PARENT_DIR}) +# Solution folders are logical names; Windows drive letters are invalid here. +set_target_properties(${LIB_NAME} PROPERTIES FOLDER "architecture/messaging") set_target_properties(${LIB_NAME} PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk") set_target_properties(${LIB_NAME} PROPERTIES LIBRARY_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/Basilisk") set_target_properties(${LIB_NAME} PROPERTIES LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Basilisk") diff --git a/src/architecture/messaging/msgAutoSource/generatePackageInit.py b/src/architecture/messaging/msgAutoSource/generatePackageInit.py index a53b33ad929..6269b86ed29 100644 --- a/src/architecture/messaging/msgAutoSource/generatePackageInit.py +++ b/src/architecture/messaging/msgAutoSource/generatePackageInit.py @@ -11,6 +11,13 @@ ".pyd", ".dylib", })) +PRIVATE_MODULE_SHIM = """# Generated by generatePackageInit.py; do not edit. +from importlib import import_module as _import_module +_import_module(__package__) +from ._load_messages import load_message_module as _load_message_module +_load_message_module(__name__.rsplit('.', 1)[-1][1:]) +del _load_message_module, _import_module +""" def read_payload_manifest(manifest_path): @@ -58,6 +65,7 @@ def payload_artifact_paths(payload_module, module_output_path, auto_source_path) auto_source_directory = Path(auto_source_path) artifact_paths = { module_output_directory / f"{payload_module}.py", + module_output_directory / f"_{payload_module}.py", module_output_directory / f"{payload_module}PYTHON_wrap.cxx", auto_source_directory / f"{payload_module}.i", auto_source_directory / f"{payload_module}_equality.h", @@ -138,14 +146,31 @@ def generate_package_init( Path(manifest_path).parent, ) + # Retire the former per-module binaries when upgrading an existing build. + # Keeping them would duplicate native code in wheels and permit accidental + # imports of stale bindings outside the package's initialization sequence. + for module_name in ["messagingSupport", *current_payloads]: + for extension_suffix in COMPILED_EXTENSION_SUFFIXES: + (output_directory / f"_{module_name}{extension_suffix}").unlink(missing_ok=True) + # Older Python versions can discover a private binding while its package + # is still initializing. A fallback shim waits for that package import. + # Normal startup populates the native module cache without reading it. + shim = output_directory / f"_{module_name}.py" + if not shim.exists() or shim.read_text(encoding="utf-8") != PRIVATE_MODULE_SHIM: + write_text_atomically(shim, PRIVATE_MODULE_SHIM) + init_content = ( + "from ._load_messages import load_message_module as _load_message_module\n" + "_load_message_module('messagingSupport')\n" "from Basilisk.architecture.messaging.messagingSupport import *\n" ) init_content += "".join( + f"_load_message_module('{payload_module}')\n" f"from Basilisk.architecture.messaging.{payload_module} import *\n" for payload_module in current_payloads ) init_content += "from Basilisk.architecture.messagingBase import *\n" + init_content += "del _load_message_module\n" output_path = output_directory / "__init__.py" write_text_atomically(output_path, init_content) diff --git a/src/architecture/system_model/_UnitTest/test_threadOwnership.py b/src/architecture/system_model/_UnitTest/test_threadOwnership.py index eba192b96da..e7e0d75c021 100644 --- a/src/architecture/system_model/_UnitTest/test_threadOwnership.py +++ b/src/architecture/system_model/_UnitTest/test_threadOwnership.py @@ -14,14 +14,26 @@ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -"""Exercise Python simulation lifetimes in subprocesses with a shutdown deadline.""" +"""Exercise Python simulation lifetimes with separate startup and shutdown deadlines.""" +import faulthandler import gc from pathlib import Path import subprocess import sys +import time import weakref +STARTUP_TIMEOUT = 120 # [s] +EXECUTION_TIMEOUT = 30 # [s] +TRACEBACK_MARGIN = 5 # [s] +STARTUP_POLL_INTERVAL = 0.05 # [s] + +if __name__ == "__main__": + # Diagnose native loading and Python imports before the parent startup limit. + faulthandler.enable() + faulthandler.dump_traceback_later(STARTUP_TIMEOUT - TRACEBACK_MARGIN) + import pytest from Basilisk.architecture import sim_model, sysModel @@ -131,22 +143,105 @@ def _run_case(case, thread_count): assert all(model.times for model in models) +def _run_with_deadlines(command, ready_path, startup_timeout=STARTUP_TIMEOUT, + execution_timeout=EXECUTION_TIMEOUT): + """Allow cold imports while retaining a separate execution and shutdown limit. + + :param command: Child interpreter command and arguments. + :param ready_path: File the child creates after importing its dependencies. + :param startup_timeout: Maximum wait for startup in seconds. + :param execution_timeout: Maximum wait for execution and process exit in seconds. + :return: Completed process including captured standard output and error. + """ + stdout_path = ready_path.with_suffix(".stdout") + stderr_path = ready_path.with_suffix(".stderr") + timed_out = False + phase = "startup" + timeout = startup_timeout + # Files cannot fill a pipe and block the child while the parent awaits readiness. + with stdout_path.open("w", encoding="utf-8") as stdout, stderr_path.open("w", encoding="utf-8") as stderr: + process = subprocess.Popen(command, stdout=stdout, stderr=stderr) + try: + deadline = time.monotonic() + startup_timeout + while not ready_path.exists() and process.poll() is None: + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(command, startup_timeout) + time.sleep(STARTUP_POLL_INTERVAL) + phase = "execution/shutdown" + timeout = execution_timeout + process.wait(timeout=execution_timeout) + except subprocess.TimeoutExpired: + timed_out = True + finally: + if process.poll() is None: + process.kill() + process.wait() + + output = stdout_path.read_text(encoding="utf-8", errors="replace") + errors = stderr_path.read_text(encoding="utf-8", errors="replace") + if timed_out: + pytest.fail(f"Child {phase} timed out after {timeout} seconds.\nstdout:\n{output}\nstderr:\n{errors}", + pytrace=False) + return subprocess.CompletedProcess(command, process.returncode, output, errors) + + +@pytest.mark.parametrize("phase", ["success", "startup", "execution"]) +def test_subprocess_deadlines_report_phase_and_output(tmp_path, phase): + """Detect stalls in either phase and retain child output for diagnosis. + + :param tmp_path: Temporary directory supplied by pytest. + :param phase: Successful completion or the phase in which the child stalls. + """ + ready_path = tmp_path / "ready" + code = """import sys, time +from pathlib import Path +if sys.argv[2] == 'startup': + time.sleep(60) # [s] +print('child stdout', flush=True) +print('child stderr', file=sys.stderr, flush=True) +Path(sys.argv[1]).touch() +if sys.argv[2] == 'execution': + time.sleep(60) # [s] +""" + command = [sys.executable, "-c", code, str(ready_path), phase] + if phase == "success": + result = _run_with_deadlines(command, ready_path) + assert result.returncode == 0, result.stderr + assert "child stdout" in result.stdout + assert "child stderr" in result.stderr + else: + # Zero deadlines make failure deterministic without relying on short sleeps. + startup_timeout = 0 if phase == "startup" else STARTUP_TIMEOUT # [s] + execution_timeout = 0 if phase == "execution" else EXECUTION_TIMEOUT # [s] + with pytest.raises(pytest.fail.Exception, match=f"Child {phase}") as error: + _run_with_deadlines(command, ready_path, startup_timeout, execution_timeout) + if phase == "execution": + assert "child stdout" in str(error.value) + assert "child stderr" in str(error.value) + + @pytest.mark.parametrize("thread_count", [1, 3]) @pytest.mark.parametrize( "case", ["unstarted", "reinitialize", "selfInit", "reset", "update", "requestStop", "killThread"] ) -def test_thread_ownership(case, thread_count): - """Preserve Python scheduling and exception handling without shutdown hangs.""" - timeout = 30 # [s] - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve()), case, str(thread_count)], - capture_output=True, - text=True, - timeout=timeout, - check=False, +def test_thread_ownership(case, thread_count, tmp_path): + """Preserve Python scheduling and exception handling without shutdown hangs. + + :param case: Simulation lifetime or exception scenario. + :param thread_count: Number of configured simulation workers. + :param tmp_path: Temporary directory supplied by pytest. + """ + ready_path = tmp_path / "ready" + result = _run_with_deadlines( + [sys.executable, str(Path(__file__).resolve()), case, str(thread_count), str(ready_path)], + ready_path, ) assert result.returncode == 0, result.stdout + result.stderr + assert ready_path.exists(), "The child exited without reaching the ownership check" if __name__ == "__main__": + faulthandler.dump_traceback_later(EXECUTION_TIMEOUT - TRACEBACK_MARGIN) + if len(sys.argv) > 3: + Path(sys.argv[3]).touch() _run_case(sys.argv[1], int(sys.argv[2])) diff --git a/src/architecture/utilities/bskException.swg b/src/architecture/utilities/bskException.swg index 407a66a5a22..eccad4269bf 100644 --- a/src/architecture/utilities/bskException.swg +++ b/src/architecture/utilities/bskException.swg @@ -3,7 +3,7 @@ %{ #include "architecture/utilities/bskLogging.h" -PyObject* get_PyExc_BasiliskError() { +static PyObject* get_PyExc_BasiliskError() { PyObject* bskLogging = PyImport_ImportModule("Basilisk.architecture.bskLogging"); PyObject* PyExc_BasiliskError = PyObject_GetAttrString(bskLogging, "BasiliskError"); diff --git a/src/architecture/utilities/tests/test_bskLogging.py b/src/architecture/utilities/tests/test_bskLogging.py index fbc5c14b228..1ee594a34de 100644 --- a/src/architecture/utilities/tests/test_bskLogging.py +++ b/src/architecture/utilities/tests/test_bskLogging.py @@ -16,6 +16,9 @@ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. # +import subprocess +import sys + import pytest from Basilisk.architecture import bskLogging @@ -40,19 +43,25 @@ def test_bsk_log_treats_python_message_as_text(): assert str(error.value) == message -def test_warning_level_output_is_flushed(capfd): +def test_warning_level_output_is_flushed(): """A ``BSK_WARNING`` must reach stdout at log time (issue #1444). - ``bskLog`` flushes warning-level (and higher) output, so it is observable to - ``capfd`` before the process exits. Without that flush the C runtime fully - buffers the line when stdout is not a TTY (pytest capture, pipes, redirection) - and it would be lost on a crash and invisible to this assertion. The log level - is set on the instance so the test does not depend on the global default level - left behind by other tests.""" - bsk_logger = bskLogging.BSKLogger() - bsk_logger.setLogLevel(bskLogging.BSK_WARNING) - - bsk_logger.bskLog(bskLogging.BSK_WARNING, "flush regression marker") - - out, _ = capfd.readouterr() - assert "flush regression marker" in out + Use an OS pipe because a Windows Debug extension has a separate C runtime + from release Python, whose file descriptors ``capfd`` redirects. The child + exits without normal C stream cleanup, so removing the warning flush loses + the marker and fails the test. + """ + result = subprocess.run( + [sys.executable, "-c", """ +import os +from Basilisk.architecture import bskLogging +logger = bskLogging.BSKLogger() +logger.setLogLevel(bskLogging.BSK_WARNING) +logger.bskLog(bskLogging.BSK_WARNING, "flush regression marker") +print("after native warning", flush=True) +os._exit(0) +"""], + capture_output=True, text=True, check=True, timeout=60, # [s] + ) + assert "flush regression marker" in result.stdout + assert result.stdout.index("flush regression marker") < result.stdout.index("after native warning") diff --git a/src/cmake/bskBindingInventory.cmake b/src/cmake/bskBindingInventory.cmake new file mode 100644 index 00000000000..ca8ff676cb8 --- /dev/null +++ b/src/cmake/bskBindingInventory.cmake @@ -0,0 +1,37 @@ +# 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_guard(GLOBAL) + +# Call after all binding targets are registered. Cleanup uses this complete +# inventory instead of inspecting shims that another group may be replacing. +function(bsk_write_binding_inventory OUTPUT) + get_property(FSW_NAMES GLOBAL PROPERTY BSK_FSW_COMBINED_MODULES) + set(NAMES) + foreach(NAME IN LISTS FSW_NAMES) + list(APPEND NAMES "fswAlgorithms.${NAME}") + endforeach() + foreach(GROUP simulationCoreNative mujocoNative opNavNative) + get_property(GROUP_NAMES GLOBAL PROPERTY BSK_${GROUP}_MODULES) + list(APPEND NAMES ${GROUP_NAMES}) + endforeach() + list(REMOVE_DUPLICATES NAMES) + list(SORT NAMES) + string(REPLACE ";" "\n" CONTENT "${NAMES}") + set(INVENTORY "${CMAKE_BINARY_DIR}/autoSource/combinedBindingModules.txt") + file(CONFIGURE OUTPUT "${INVENTORY}" CONTENT "${CONTENT}\n" @ONLY) + set(${OUTPUT} "${INVENTORY}" PARENT_SCOPE) +endfunction() diff --git a/src/cmake/bskCombineFswBindings.cmake b/src/cmake/bskCombineFswBindings.cmake new file mode 100644 index 00000000000..c67ee503569 --- /dev/null +++ b/src/cmake/bskCombineFswBindings.cmake @@ -0,0 +1,102 @@ +# 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_guard(GLOBAL) +include("${CMAKE_CURRENT_LIST_DIR}/bskBindingInventory.cmake") + +function(bsk_finalize_fsw_bindings) + bsk_write_binding_inventory(ACTIVE_MANIFEST) + get_property(OBJECT_TARGETS GLOBAL PROPERTY BSK_FSW_OBJECT_TARGETS) + get_property(MODULE_NAMES GLOBAL PROPERTY BSK_FSW_COMBINED_MODULES) + get_property(BINDING_TARGETS GLOBAL PROPERTY BSK_FSW_BINDING_TARGETS) + set(PACKAGE_DIR "${CMAKE_BINARY_DIR}/Basilisk/fswAlgorithms") + set(MANIFEST "${CMAKE_BINARY_DIR}/autoSource/fswCoreBindings.txt") + set(STAMP "${CMAKE_BINARY_DIR}/autoSource/fswBindingsLayout.stamp") + list(SORT MODULE_NAMES) + string(REPLACE ";" "\n" MANIFEST_CONTENT "${MODULE_NAMES}") + file(CONFIGURE OUTPUT "${MANIFEST}" CONTENT "${MANIFEST_CONTENT}\n" @ONLY) + + set(LAYOUT_OUTPUTS "${STAMP}") + foreach(MODULE_NAME IN LISTS MODULE_NAMES) + list(APPEND LAYOUT_OUTPUTS "${PACKAGE_DIR}/_${MODULE_NAME}.py") + endforeach() + if(OBJECT_TARGETS) + list(APPEND LAYOUT_OUTPUTS "${PACKAGE_DIR}/_load_fsw.py") + # Xcode needs a source on the aggregate target to create its link phase. + # Preserve the file's timestamp so reconfiguration does not force a relink. + set(NATIVE_SOURCE "${CMAKE_BINARY_DIR}/autoSource/fswCoreNative.cpp") + file(CONFIGURE OUTPUT "${NATIVE_SOURCE}" CONTENT + "// Generated by bskCombineFswBindings.cmake.\n// This empty translation unit enables the Xcode link phase.\n" @ONLY) + add_library(fswCoreNative MODULE "${NATIVE_SOURCE}") + target_link_libraries(fswCoreNative PRIVATE ${OBJECT_TARGETS}) + set_target_properties(fswCoreNative PROPERTIES + PREFIX "_" NO_SONAME ON LINKER_LANGUAGE CXX FOLDER "fswAlgorithms" + LIBRARY_OUTPUT_DIRECTORY "${PACKAGE_DIR}" + RUNTIME_OUTPUT_DIRECTORY "${PACKAGE_DIR}" + ARCHIVE_OUTPUT_DIRECTORY "${PACKAGE_DIR}") + foreach(CONFIG IN ITEMS DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) + set_target_properties(fswCoreNative PROPERTIES + LIBRARY_OUTPUT_DIRECTORY_${CONFIG} "${PACKAGE_DIR}" + RUNTIME_OUTPUT_DIRECTORY_${CONFIG} "${PACKAGE_DIR}" + ARCHIVE_OUTPUT_DIRECTORY_${CONFIG} "${PACKAGE_DIR}") + endforeach() + if(WIN32) + set_target_properties(fswCoreNative PROPERTIES SUFFIX ".pyd") + endif() + list(APPEND BINDING_TARGETS fswCoreNative) + endif() + + add_custom_command( + OUTPUT ${LAYOUT_OUTPUTS} + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/cmake/generateFswBindings.py" + "${MANIFEST}" "${PACKAGE_DIR}" "${CMAKE_SOURCE_DIR}/fswAlgorithms/_load_fsw.py" "${ACTIVE_MANIFEST}" + COMMAND "${CMAKE_COMMAND}" -E touch "${STAMP}" + DEPENDS "${MANIFEST}" "${ACTIVE_MANIFEST}" "${CMAKE_SOURCE_DIR}/cmake/generateFswBindings.py" + "${CMAKE_SOURCE_DIR}/fswAlgorithms/_load_fsw.py" + COMMENT "Updating the FSW binding layout" + VERBATIM) + add_custom_target(fswBindingsLayout DEPENDS ${LAYOUT_OUTPUTS}) + foreach(BINDING_TARGET IN LISTS BINDING_TARGETS) + add_dependencies(${BINDING_TARGET} fswBindingsLayout) + endforeach() +endfunction() + +function(bsk_isolate_fsw_symbols MODULE_NAME) + # Historically these helpers had external linkage in separate libraries. + # Prefix their compiled C symbols within the combined library, including calls + # from the generated wrapper. Do not pass the definitions to SWIG: its public + # Python names must remain unchanged. + set(SYMBOLS) + if(MODULE_NAME MATCHES "^(okeefeEKF|sunlineEKF|sunlineSEKF)$") + list(APPEND SYMBOLS sunlineMeasUpdate sunlineStateSTMProp sunlineHMatrixYMeas sunlineTimeUpdate) + endif() + if(MODULE_NAME MATCHES "^(sunlineEKF|sunlineSEKF)$") + list(APPEND SYMBOLS sunlineCKFUpdate sunlineDynMatrix sunlineKalmanGain) + endif() + if(MODULE_NAME MATCHES "^(sunlineSuKF|sunlineUKF)$") + list(APPEND SYMBOLS sunlineStateProp) + endif() + if(MODULE_NAME MATCHES "^(eulerRotation|mrpRotation)$") + list(APPEND SYMBOLS checkRasterCommands computeTimeStep) + endif() + if(MODULE_NAME MATCHES "^(pixelLineBiasUKF|relativeODuKF)$") + list(APPEND SYMBOLS relODStateProp) + endif() + foreach(SYMBOL IN LISTS SYMBOLS) + set_property(SOURCE ${ARGN} APPEND PROPERTY COMPILE_DEFINITIONS + "${SYMBOL}=bsk_${MODULE_NAME}_${SYMBOL}") + endforeach() +endfunction() diff --git a/src/cmake/bskCombineSimulationBindings.cmake b/src/cmake/bskCombineSimulationBindings.cmake new file mode 100644 index 00000000000..7007cd479b9 --- /dev/null +++ b/src/cmake/bskCombineSimulationBindings.cmake @@ -0,0 +1,114 @@ +# 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_guard(GLOBAL) +include("${CMAKE_CURRENT_LIST_DIR}/bskBindingInventory.cmake") + +function(bsk_select_simulation_binding_group OUTPUT MODULE_NAME PARENT_DIR MODULE_DIR) + set(GROUP "") + if(MODULE_DIR STREQUAL "simulation" OR MODULE_DIR STREQUAL "fswAlgorithms") + bsk_collect_wrapper_custom_files(CUSTOM_FILES "${PARENT_DIR}" "${MODULE_DIR}" + "${CMAKE_SOURCE_DIR}" "${EXTERNAL_MODULES_PATH}" "${BSK_CUSTOM_CMAKE_FILES}") + set(USES_OPENCV FALSE) + foreach(CUSTOM_FILE IN LISTS CUSTOM_FILES) + file(READ "${CUSTOM_FILE}" CUSTOM_CONTENT) + if(CUSTOM_CONTENT MATCHES "include[ \t]*\\([ \t]*usingOpenCV[ \t]*\\)") + set(USES_OPENCV TRUE) + endif() + endforeach() + if(USES_OPENCV) + set(GROUP opNavNative) + elseif(MODULE_DIR STREQUAL "simulation") + if(PARENT_DIR MATCHES "(^|/)mujocoDynamics(/|$)") + set(GROUP mujocoNative) + else() + # These two custom files add only existing core Basilisk libraries. + # Keep Vizard and unrecognized custom dependencies outside the core group. + if(NOT CUSTOM_FILES OR MODULE_NAME MATCHES "^(downlinkHandling|spacecraftChargingDynamics)$") + set(GROUP simulationCoreNative) + endif() + endif() + endif() + endif() + set(${OUTPUT} "${GROUP}" PARENT_SCOPE) +endfunction() + +function(bsk_add_binding_group GROUP PACKAGE LOADER) + bsk_write_binding_inventory(ACTIVE_MANIFEST) + get_property(OBJECT_TARGETS GLOBAL PROPERTY BSK_${GROUP}_OBJECT_TARGETS) + get_property(MODULE_NAMES GLOBAL PROPERTY BSK_${GROUP}_MODULES) + list(SORT MODULE_NAMES) + string(REPLACE ";" "\n" CONTENT "${MODULE_NAMES}") + set(MANIFEST "${CMAKE_BINARY_DIR}/autoSource/${GROUP}Bindings.txt") + file(CONFIGURE OUTPUT "${MANIFEST}" CONTENT "${CONTENT}\n" @ONLY) + get_property(PROXIES GLOBAL PROPERTY BSK_${GROUP}_PROXIES) + list(SORT PROXIES) + string(REPLACE ";" "\n" PROXY_CONTENT "${PROXIES}") + file(CONFIGURE OUTPUT "${CMAKE_BINARY_DIR}/autoSource/${GROUP}Proxies.txt" + CONTENT "${PROXY_CONTENT}\n" @ONLY) + set(PACKAGE_ROOT "${CMAKE_BINARY_DIR}/Basilisk") + set(OUTPUT_DIR "${PACKAGE_ROOT}/${PACKAGE}") + set(NATIVE_NAME "_${GROUP}") + if(PACKAGE) + set(NATIVE_NAME "${PACKAGE}.${NATIVE_NAME}") + endif() + set(STAMP "${CMAKE_BINARY_DIR}/autoSource/${GROUP}Layout.stamp") + set(OUTPUTS "${STAMP}") + foreach(MODULE_NAME IN LISTS MODULE_NAMES) + string(REGEX REPLACE "^([^.]+)\\.(.+)$" "\\1/_\\2.py" SHIM "${MODULE_NAME}") + list(APPEND OUTPUTS "${PACKAGE_ROOT}/${SHIM}") + endforeach() + if(OBJECT_TARGETS) + # A source on the aggregate is required for Xcode to create its link phase. + set(ANCHOR "${CMAKE_BINARY_DIR}/autoSource/${GROUP}.cpp") + file(CONFIGURE OUTPUT "${ANCHOR}" CONTENT "// Generated empty translation unit for the Xcode link phase.\n" @ONLY) + add_library(${GROUP} MODULE "${ANCHOR}") + target_link_libraries(${GROUP} PRIVATE ${OBJECT_TARGETS}) + set_target_properties(${GROUP} PROPERTIES PREFIX "_" NO_SONAME ON LINKER_LANGUAGE CXX FOLDER "simulation") + foreach(KIND LIBRARY RUNTIME ARCHIVE) + set_target_properties(${GROUP} PROPERTIES ${KIND}_OUTPUT_DIRECTORY "${OUTPUT_DIR}") + foreach(CONFIG DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) + set_target_properties(${GROUP} PROPERTIES ${KIND}_OUTPUT_DIRECTORY_${CONFIG} "${OUTPUT_DIR}") + endforeach() + endforeach() + if(WIN32) + set_target_properties(${GROUP} PROPERTIES SUFFIX ".pyd") + endif() + string(REPLACE "." "/" LOADER_PATH "${LOADER}") + list(APPEND OUTPUTS "${PACKAGE_ROOT}/${LOADER_PATH}.py") + add_dependencies(${GROUP} ${GROUP}Layout) + endif() + add_custom_command(OUTPUT ${OUTPUTS} + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/cmake/generateGroupedBindings.py" + "${MANIFEST}" "${PACKAGE_ROOT}" "${CMAKE_SOURCE_DIR}/fswAlgorithms/_load_fsw.py" + "${NATIVE_NAME}" "${LOADER}" "${ACTIVE_MANIFEST}" + COMMAND "${CMAKE_COMMAND}" -E touch "${STAMP}" + DEPENDS "${MANIFEST}" "${ACTIVE_MANIFEST}" "${CMAKE_SOURCE_DIR}/cmake/generateGroupedBindings.py" + "${CMAKE_SOURCE_DIR}/fswAlgorithms/_load_fsw.py" + COMMENT "Updating ${GROUP} binding layout" VERBATIM) + add_custom_target(${GROUP}Layout DEPENDS ${OUTPUTS}) + # Clean old binaries/shims before either separate or combined targets build. + get_property(BINDING_TARGETS GLOBAL PROPERTY BSK_SIMULATION_BINDING_TARGETS) + foreach(BINDING_TARGET IN LISTS BINDING_TARGETS) + add_dependencies(${BINDING_TARGET} ${GROUP}Layout) + endforeach() +endfunction() + +function(bsk_finalize_simulation_bindings) + bsk_add_binding_group(simulationCoreNative "simulation" "simulation._load_simulation") + bsk_add_binding_group(mujocoNative "simulation" "simulation._load_mujoco") + bsk_add_binding_group(opNavNative "" "_load_opnav") +endfunction() diff --git a/src/cmake/generateFswBindings.py b/src/cmake/generateFswBindings.py new file mode 100644 index 00000000000..b20dfd29d6c --- /dev/null +++ b/src/cmake/generateFswBindings.py @@ -0,0 +1,80 @@ +# 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. + +"""Generate on-demand FSW import shims and clean up a previous build layout.""" + +import importlib.machinery +from pathlib import Path +import re +import sys + +SHIM = """# Generated by generateFswBindings.py; do not edit. +from ._load_fsw import load_native_module as _load_native_module +_load_native_module(__name__) +del _load_native_module +""" + + +def write_if_changed(path: Path, content: str) -> None: + """Write a generated file only when its contents change.""" + if not path.exists() or path.read_text() != content: + path.write_text(content) + + +def read_names(path: Path) -> set[str]: + """Read and validate a manifest of module names.""" + names = set(path.read_text().split()) if path.exists() else set() + if any(re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name) is None for name in names): + raise ValueError(f"Invalid FSW module name in {path}") + return names + + +def remove_native(package_dir: Path, name: str) -> None: + """Remove only a named extension's platform-specific build outputs.""" + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + (package_dir / f"_{name}{suffix}").unlink(missing_ok=True) + + +def generate_layout(manifest: Path, package_dir: Path, loader: Path, active_manifest: Path) -> None: + """Update the package layout when the combined binding inventory changes. + + :param manifest: Manifest of bindings in the combined library, empty when no bindings remain. + :param package_dir: Generated FSW Python package directory. + :param loader: Source file for the combined-library loader. + :param active_manifest: Current dotted module names across all binding groups. + """ + names = read_names(manifest) + previous = manifest.with_suffix(".previous.txt") + old_names = read_names(previous) + active_names = set(active_manifest.read_text(encoding="utf-8").split()) + package_dir.mkdir(parents=True, exist_ok=True) + for name in old_names - names: + # Another group may be writing this shim concurrently after a move. + if f"fswAlgorithms.{name}" not in active_names: + (package_dir / f"_{name}.py").unlink(missing_ok=True) + for name in names: + remove_native(package_dir, name) + write_if_changed(package_dir / f"_{name}.py", SHIM) + if names: + write_if_changed(package_dir / "_load_fsw.py", loader.read_text()) + else: + (package_dir / "_load_fsw.py").unlink(missing_ok=True) + remove_native(package_dir, "fswCoreNative") + write_if_changed(previous, manifest.read_text()) + + +if __name__ == "__main__": + generate_layout(*(Path(argument) for argument in sys.argv[1:])) diff --git a/src/cmake/generateGroupedBindings.py b/src/cmake/generateGroupedBindings.py new file mode 100644 index 00000000000..82ae6a4ab52 --- /dev/null +++ b/src/cmake/generateGroupedBindings.py @@ -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. + +"""Generate independent import layouts for simulation and optional binding groups.""" + +import importlib.machinery +from pathlib import Path +import re +import sys + + +def module_path(root: Path, name: str) -> Path: + """Resolve a validated dotted module name inside a package root.""" + if re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*(?:\.[A-Za-z_][A-Za-z_0-9]*)*", name) is None: + raise ValueError(f"Invalid binding module name: {name}") + return root.joinpath(*name.split(".")) + + +def write_if_changed(path: Path, content: str) -> None: + """Write generated text without changing the timestamp of identical files.""" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists() or path.read_text(encoding="utf-8") != content: + path.write_text(content, encoding="utf-8") + + +def remove_native(path: Path) -> None: + """Remove an extension and its Windows import libraries, retaining other files.""" + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + path.with_name(path.name + suffix).unlink(missing_ok=True) + # UseSWIG and plain MODULE targets can choose different import-library prefixes. + for stem in {path.name, path.name.removeprefix("_")}: + path.with_name(stem + ".lib").unlink(missing_ok=True) + + +def generate_layout( + manifest: Path, root: Path, loader: Path, native: str, loader_name: str, active_manifest: Path, +) -> None: + """Create lazy import shims and retire a group's previous layout. + + :param manifest: Private extension base names, without underscores, relative to Basilisk. + :param root: Generated Basilisk package directory. + :param loader: Source of the shared native loader implementation. + :param native: Dotted name of the native container relative to Basilisk. + :param loader_name: Dotted destination of the loader relative to Basilisk. + :param active_manifest: Current dotted module names across all binding groups. + """ + previous = manifest.with_suffix(".previous.txt") + names = set(manifest.read_text(encoding="utf-8").split()) + old_names = set(previous.read_text(encoding="utf-8").split()) if previous.exists() else set() + active_names = set(active_manifest.read_text(encoding="utf-8").split()) + paths = {name: module_path(root, name) for name in names | old_names} + native_path = module_path(root, native) + loader_path = module_path(root, loader_name).with_suffix(".py") + # Use the configured inventory so cleanup cannot race another group's write. + for name in old_names - names - active_names: + path = paths[name] + path.with_name("_" + path.name + ".py").unlink(missing_ok=True) + for name in sorted(names): + path = paths[name].with_name("_" + paths[name].name) + remove_native(path) + shim = ("# Generated by generateGroupedBindings.py; do not edit.\n" + f"from Basilisk.{loader_name} import load_native_module as _load_native_module\n" + f"_load_native_module(__name__, 'Basilisk.{native}')\n" + "del _load_native_module\n") + write_if_changed(path.with_suffix(".py"), shim) + if names: + write_if_changed(loader_path, loader.read_text(encoding="utf-8")) + else: + loader_path.unlink(missing_ok=True) + remove_native(native_path) + write_if_changed(previous, manifest.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + generate_layout(*(Path(arg) for arg in sys.argv[1:4]), *sys.argv[4:6], Path(sys.argv[6])) diff --git a/src/cmake/prepareGroupedWrapper.py b/src/cmake/prepareGroupedWrapper.py new file mode 100644 index 00000000000..5186e533a0d --- /dev/null +++ b/src/cmake/prepareGroupedWrapper.py @@ -0,0 +1,48 @@ +# 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. + +"""Share SWIG's director mutex across wrappers without changing director RTTI.""" + +from pathlib import Path +import re +import sys + + +def prepare_wrapper(source: Path, destination: Path) -> None: + """Make the generated director mutex an inline C++17 variable. + + SWIG emits one external mutex definition per director wrapper. A combined + library needs a single shared definition while retaining the common director + class for cross-module casts and Python callbacks. + + :param source: Original SWIG-generated C++ wrapper. + :param destination: C++ wrapper compiled by the combined target. + :raises ValueError: If a director wrapper uses an unrecognized mutex definition. + """ + content = source.read_text(encoding="utf-8") + if "#define SWIG_DIRECTORS" in content: + # SWIG 4.5 moved the trailing semicolon into the macro definition. + definition = r"^([ \t]*)(SWIG_GUARD_DEFINITION\(Director, swig_mutex_own\);?)[ \t]*$" + # The declaration macro can be empty when threading is disabled. + replacement = r"\1#ifdef SWIG_THREADS\n\1inline \2\n\1#endif" + content, count = re.subn(definition, replacement, content, flags=re.MULTILINE) + if count != 1: + raise ValueError(f"Unrecognized SWIG director mutex in {source}: found {count} definitions") + destination.write_text(content, encoding="utf-8") + + +if __name__ == "__main__": + prepare_wrapper(*(Path(arg) for arg in sys.argv[1:3])) diff --git a/src/fswAlgorithms/_load_fsw.py b/src/fswAlgorithms/_load_fsw.py new file mode 100644 index 00000000000..bdb5eba638a --- /dev/null +++ b/src/fswAlgorithms/_load_fsw.py @@ -0,0 +1,71 @@ +# 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. + +"""Load individual extensions from a combined native library on demand.""" + +import importlib.util as _import_util +import sys as _sys + +_library_paths = {} + + +def load_native_module(full_name: str, + library_name: str = "Basilisk.fswAlgorithms._fswCoreNative") -> None: + """Replace a generated import shim with its original SWIG extension. + + Each binding is initialized only when its Python proxy or private extension + is imported. Python's normal import lock protects each module name. + Reloading a private extension preserves the existing native module. + + :param full_name: Fully qualified name of the private SWIG extension. + :param library_name: Fully qualified name of the combined native library. + :raises ImportError: If the native binding cannot be loaded. + """ + placeholder = _sys.modules[full_name] + native_spec = getattr(placeholder, "_bsk_fsw_native_spec", None) + if native_spec is not None: + # reload() finds the Python shim and overwrites these attributes before + # executing it. Restore the native metadata without initializing again. + placeholder.__spec__ = native_spec + placeholder.__loader__ = native_spec.loader + placeholder.__file__ = native_spec.origin + placeholder.__package__ = native_spec.parent + placeholder.__dict__.pop("__cached__", None) + return + + if library_name not in _library_paths: + library_spec = _import_util.find_spec(library_name) + if library_spec is None or library_spec.origin is None: + raise ImportError(f"The combined native library {library_name} is missing.") + _library_paths[library_name] = library_spec.origin + library_path = _library_paths[library_name] + spec = _import_util.spec_from_file_location(full_name, library_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load {full_name} from {library_path}.") + module = _import_util.module_from_spec(spec) + # Preserve importlib's initialization flag before publishing the replacement. + # Otherwise concurrent imports bypass the shim's module lock and can use + # native functions before SWIG has initialized their types. + spec._initializing = True + try: + _sys.modules[full_name] = module + spec.loader.exec_module(module) + module._bsk_fsw_native_spec = spec + except BaseException: + _sys.modules[full_name] = placeholder + raise + finally: + spec._initializing = False diff --git a/src/fswAlgorithms/attDetermination/headingSuKF/headingSuKF.c b/src/fswAlgorithms/attDetermination/headingSuKF/headingSuKF.c index 27bbbe3ad6d..b588863224d 100644 --- a/src/fswAlgorithms/attDetermination/headingSuKF/headingSuKF.c +++ b/src/fswAlgorithms/attDetermination/headingSuKF/headingSuKF.c @@ -519,8 +519,9 @@ void headingSuKFMeasUpdate(HeadingSuKFConfig *configData, double updateTime) get the total shifted S matrix (called sBar in internal parameters*/ for(int i=0; inumStates]), (size_t) configData->numStates, tempYVec); - ukfCholDownDate(configData->sBar, tempYVec, -1.0, configData->numStates, sBarT); + /* A column of U has numStates entries, not OPNAV_MEAS entries. */ + vCopy(&(pXY[(size_t) i * (size_t) configData->numStates]), (size_t) configData->numStates, xHat); + ukfCholDownDate(configData->sBar, xHat, -1.0, configData->numStates, sBarT); mCopy(sBarT, (size_t) configData->numStates, (size_t) configData->numStates, configData->sBar); } diff --git a/src/fswAlgorithms/attGuidance/oneAxisSolarArrayPoint/oneAxisSolarArrayPoint.c b/src/fswAlgorithms/attGuidance/oneAxisSolarArrayPoint/oneAxisSolarArrayPoint.c index 1fd8bceae91..b57b22e680b 100755 --- a/src/fswAlgorithms/attGuidance/oneAxisSolarArrayPoint/oneAxisSolarArrayPoint.c +++ b/src/fswAlgorithms/attGuidance/oneAxisSolarArrayPoint/oneAxisSolarArrayPoint.c @@ -26,7 +26,7 @@ #include "architecture/utilities/rigidBodyKinematics.h" #include "architecture/utilities/macroDefinitions.h" -const double epsilon = 1e-12; // module tolerance for zero +static const double epsilon = 1e-12; // [-] Module tolerance for zero /*! This method initializes the output messages for this module. diff --git a/src/fswAlgorithms/effectorInterfaces/solarArrayReference/solarArrayReference.c b/src/fswAlgorithms/effectorInterfaces/solarArrayReference/solarArrayReference.c index 11858b0dd53..061c0d5b7e8 100644 --- a/src/fswAlgorithms/effectorInterfaces/solarArrayReference/solarArrayReference.c +++ b/src/fswAlgorithms/effectorInterfaces/solarArrayReference/solarArrayReference.c @@ -26,7 +26,7 @@ #include "architecture/utilities/rigidBodyKinematics.h" #include "architecture/utilities/macroDefinitions.h" -const double epsilon = 1e-12; // module tolerance for zero +static const double epsilon = 1e-12; // [-] Module tolerance for zero /*! This method initializes the output messages for this module. diff --git a/src/fswAlgorithms/effectorInterfaces/thrusterPlatformReference/thrusterPlatformReference.c b/src/fswAlgorithms/effectorInterfaces/thrusterPlatformReference/thrusterPlatformReference.c index 2ad377f7cf0..feb503f287e 100755 --- a/src/fswAlgorithms/effectorInterfaces/thrusterPlatformReference/thrusterPlatformReference.c +++ b/src/fswAlgorithms/effectorInterfaces/thrusterPlatformReference/thrusterPlatformReference.c @@ -23,7 +23,7 @@ #include "architecture/utilities/linearAlgebra.h" #include "architecture/utilities/rigidBodyKinematics.h" -const double epsilon = 1e-12; // module tolerance for zero +static const double epsilon = 1e-12; // [-] Module tolerance for zero /*! This method initializes the output messages for this module. diff --git a/src/fswAlgorithms/imageProcessing/horizonOpNav/_UnitTest/test_horizonOpNav.py b/src/fswAlgorithms/imageProcessing/horizonOpNav/_UnitTest/test_horizonOpNav.py index 8fe697426fe..15d0f12f018 100644 --- a/src/fswAlgorithms/imageProcessing/horizonOpNav/_UnitTest/test_horizonOpNav.py +++ b/src/fswAlgorithms/imageProcessing/horizonOpNav/_UnitTest/test_horizonOpNav.py @@ -9,6 +9,8 @@ import os import numpy as np +import pytest +from Basilisk.architecture import bskLogging from Basilisk.architecture import messaging from Basilisk.fswAlgorithms import horizonOpNav from Basilisk.utilities import RigidBodyKinematics as rbk @@ -37,7 +39,8 @@ def back_substitution(A, b): return x -def test_horizonOpNav(): +@pytest.mark.parametrize("configuredPlanet, messagePlanet", [(2, 0), (0, 2), (1, 2)]) +def test_horizonOpNav(configuredPlanet, messagePlanet): """ Unit test for Horizon Navigation. The unit test specifically covers: @@ -53,7 +56,7 @@ def test_horizonOpNav(): """ [testResults, testMessage] = horizonOpNav_methods() assert testResults < 1, testMessage - [testResults, testMessage] = horizonOpNav_update() + [testResults, testMessage] = horizonOpNav_update(configuredPlanet, messagePlanet) assert testResults < 1, testMessage def horizonOpNav_methods(): @@ -152,7 +155,7 @@ def horizonOpNav_methods(): ################################################################################### ## Testing dynamics matrix computation ################################################################################### -def horizonOpNav_update(): +def horizonOpNav_update(configuredPlanet=2, messagePlanet=0): # Create a sim module as an empty container testFailCount = 0 # zero unit test result counter testMessages = [] # create empty array to store test log messages @@ -263,6 +266,7 @@ def horizonOpNav_update(): # Set circles inputLimbMsg.valid = 1 + inputLimbMsg.planetIds = messagePlanet inputLimbMsg.limbPoints = inputPoints inputLimbMsg.numLimbPoints = int(len(inputPoints)/2) inputLimbMsg.timeTag = 12345 @@ -277,7 +281,7 @@ def horizonOpNav_update(): # Set module for Mars - opNav.planetTarget = 2 + opNav.planetTarget = configuredPlanet dataLog = opNav.opNavOutMsg.recorder() unitTestSim.AddModelToTask(unitTaskName, dataLog) @@ -286,6 +290,8 @@ def horizonOpNav_update(): # The result isn't going to change with more time. The module will continue to produce the same result unitTestSim.ConfigureStopTime(testProcessRate) # seconds to stop simulation unitTestSim.ExecuteSimulation() + assert opNav.planetTarget == 2 + np.testing.assert_array_equal(dataLog.planetID, 2) # Truth Vlaues ############################ @@ -376,17 +382,15 @@ def horizonOpNav_update(): outputR = dataLog.r_BN_C outputCovar = dataLog.covar_C - outputTime = dataLog.timeTag - for i in range(len(outputR[-1, 1:])): - if np.abs((r_BN_C[i] - outputR[0, i])/r_BN_C[i]) > posErr or np.isnan(outputR.any()): - testFailCount += 1 - testMessages.append("FAILED: Position Check in Horizon Nav for index "+ str(i) + " with error " + str(np.abs((r_BN_C[i] - outputR[-1, i+1])/r_BN_C[i]))) - - for i in range(len(outputCovar[-1, 1:])): - if np.abs((Covar_C_test.flatten()[i] - outputCovar[0, i])/Covar_C_test.flatten()[i]) > covarErr or np.isnan(outputTime.any()): - testFailCount += 1 - testMessages.append("FAILED: Covar Check in Horizon Nav for index "+ str(i) + " with error " + str(np.abs((Covar_C_test.flatten()[i] - outputCovar[-1, i+1])))) + # Validate every component at both updates; the second update previously + # used uninitialized radii after a zero message ID erased planetTarget. + assert np.isfinite(outputR).all() + assert np.isfinite(outputCovar).all() + np.testing.assert_allclose(outputR, np.broadcast_to(r_BN_C, outputR.shape), rtol=posErr) + np.testing.assert_allclose( + outputCovar, np.broadcast_to(Covar_C_test.flatten(), outputCovar.shape), rtol=covarErr, + ) snippentName = "passFail" if testFailCount == 0: @@ -404,6 +408,26 @@ def horizonOpNav_update(): return [testFailCount, ''.join(testMessages)] +@pytest.mark.parametrize("configuredPlanet, messagePlanet", [(0, 0), (4, 0), (2, -1), (2, 4), (2, float("nan"))]) +def test_horizonOpNav_rejects_unknown_planet(configuredPlanet, messagePlanet): + """Reject unsupported targets before reading uninitialized planet radii.""" + module = horizonOpNav.horizonOpNav() + module.planetTarget = configuredPlanet + camera = messaging.CameraConfigMsg().write(messaging.CameraConfigMsgPayload()) + attitude = messaging.NavAttMsg().write(messaging.NavAttMsgPayload()) + limbPayload = messaging.OpNavLimbMsgPayload() + limbPayload.valid = 1 + limbPayload.planetIds = messagePlanet + limb = messaging.OpNavLimbMsg().write(limbPayload) + module.cameraConfigInMsg.subscribeTo(camera) + module.attInMsg.subscribeTo(attitude) + module.limbInMsg.subscribeTo(limb) + module.SelfInit() + module.Reset(0) + with pytest.raises(bskLogging.BasiliskError, match="horizonOpNav:.*planet"): + module.UpdateState(0) + + if __name__ == '__main__': # horizonOpNav_methods() horizonOpNav_update() diff --git a/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.c b/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.c index ffecfcf4948..5d9ab5867fe 100644 --- a/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.c +++ b/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.c @@ -89,6 +89,17 @@ void Update_horizonOpNav(HorizonOpNavData *configData, uint64_t callTime, int64_ OpNavMsg_C_write(&opNavMsgOut, &configData->opNavOutMsg, moduleID, callTime); return; } + /* A positive message ID overrides the configured target for this update. + A zero ID means unspecified and must not erase the configured target. */ + if (limbIn.planetIds != 0.0) { + if (limbIn.planetIds != 1.0 && limbIn.planetIds != 2.0 && limbIn.planetIds != 3.0) { + _bskError(configData->bskLogger, "horizonOpNav: unsupported limb planet ID."); + } + configData->planetTarget = (int32_t) limbIn.planetIds; + } + if (configData->planetTarget < 1 || configData->planetTarget > 3) { + _bskError(configData->bskLogger, "horizonOpNav: planetTarget must be Earth (1), Mars (2), or Jupiter (3)."); + } /*! Create Q matrix, the square root inverse of the A matrix, eq (6) in Engineering Note*/ if(configData->planetTarget ==1){ planetRad_Eq = REQ_EARTH*1E3;//in m @@ -121,7 +132,6 @@ void Update_horizonOpNav(HorizonOpNavData *configData, uint64_t callTime, int64_ sigma_pix = configData->noiseSF*cameraSpecs.resolution[0]/(numPoints); /*! Build DCMs */ - configData->planetTarget = (int32_t) limbIn.planetIds; MRP2C(cameraSpecs.sigma_CB, dcm_CB); MRP2C(attInfo.sigma_BN, dcm_BN); m33MultM33(dcm_CB, dcm_BN, dcm_NC); diff --git a/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.rst b/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.rst index cfb0ef732da..1f36ff3b2d8 100644 --- a/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.rst +++ b/src/fswAlgorithms/imageProcessing/horizonOpNav/horizonOpNav.rst @@ -25,3 +25,11 @@ description provides information on what this message is used for. attitude input message input limbInMsg OpNavLimbMsgPayload limb input message + +Planet Selection +---------------- + +Set ``planetTarget`` to 1 (Earth), 2 (Mars), or 3 (Jupiter). For a valid limb +measurement, a nonzero ``planetIds`` in ``limbInMsg`` replaces ``planetTarget`` +before the navigation calculation. A zero ID leaves the current target unchanged. +Unsupported IDs raise ``BasiliskError`` in Python. diff --git a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/_UnitTest/test_smallBodyNavEKF.py b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/_UnitTest/test_smallBodyNavEKF.py index 7f6a602c163..d2ba27186f1 100644 --- a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/_UnitTest/test_smallBodyNavEKF.py +++ b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/_UnitTest/test_smallBodyNavEKF.py @@ -18,7 +18,8 @@ # import numpy as np -from Basilisk.architecture import messaging +import pytest +from Basilisk.architecture import bskLogging, messaging from Basilisk.fswAlgorithms import smallBodyNavEKF from Basilisk.utilities import SimulationBaseClass from Basilisk.utilities import macros, orbitalMotion @@ -26,6 +27,39 @@ from matplotlib import pyplot as plt +@pytest.mark.parametrize("method", ["Reset", "UpdateState"]) +@pytest.mark.parametrize("field, shape", [ + ("x_hat_k", (11,)), + ("x_hat_k", (18,)), + ("P_k", (11, 12)), + ("P_k", (12, 13)), + ("Q", (11, 12)), + ("Q", (12, 13)), + ("R", (11, 12)), + ("R", (12, 13)), +]) +def test_smallBodyNavEKF_rejects_invalid_dimensions(method, field, shape): + """Reject malformed configuration at reset and after later Python reassignment.""" + module = smallBodyNavEKF.SmallBodyNavEKF() + module.x_hat_k = np.zeros(12).tolist() + for matrix in ("P_k", "Q", "R"): + setattr(module, matrix, np.eye(12).tolist()) + inputs = { + "navTransInMsg": messaging.NavTransMsg().write(messaging.NavTransMsgPayload()), + "navAttInMsg": messaging.NavAttMsg().write(messaging.NavAttMsgPayload()), + "asteroidEphemerisInMsg": messaging.EphemerisMsg().write(messaging.EphemerisMsgPayload()), + "sunEphemerisInMsg": messaging.EphemerisMsg().write(messaging.EphemerisMsgPayload()), + } + for name, message in inputs.items(): + getattr(module, name).subscribeTo(message) + module.SelfInit() + module.Reset(0) + + setattr(module, field, np.zeros(shape).tolist()) + with pytest.raises(bskLogging.BasiliskError, match=rf"SmallBodyNavEKF\.{field} must .*12"): + getattr(module, method)(0) + + def test_smallBodyNavEKF(show_plots): r""" **Validation Test Description** diff --git a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.cpp b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.cpp index 5aa57d98094..0d3d3d85249 100644 --- a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.cpp +++ b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.cpp @@ -75,11 +75,13 @@ void SmallBodyNavEKF::SelfInit(){ EphemerisMsg_C_init(&this->asteroidEphemerisOutMsgC); } -/*! This method is used to reset the module and checks that required input messages are connect. - +/*! @brief Validate filter dimensions and required input message connections. + @param CurrentSimNanos Current simulation time [ns]. */ void SmallBodyNavEKF::Reset(uint64_t CurrentSimNanos [[maybe_unused]]) { + this->validateStateDimensions(); + /* check that required input messages are connected */ if (!this->navTransInMsg.isLinked()) { bskLogger.bskError("SmallBodyNavEKF.navTransInMsg was not linked."); @@ -96,6 +98,23 @@ void SmallBodyNavEKF::Reset(uint64_t CurrentSimNanos [[maybe_unused]]) } +/*! @brief Reject invalid dimensions before converting Python-facing dynamic arrays to fixed-size Eigen storage. */ +void SmallBodyNavEKF::validateStateDimensions() +{ + if (this->x_hat_k.size() != stateSize) { + bskLogger.bskError("SmallBodyNavEKF.x_hat_k must contain exactly %d states.", stateSize); + } + if (this->P_k.rows() != stateSize || this->P_k.cols() != stateSize) { + bskLogger.bskError("SmallBodyNavEKF.P_k must be a %d-by-%d matrix.", stateSize, stateSize); + } + if (this->Q.rows() != stateSize || this->Q.cols() != stateSize) { + bskLogger.bskError("SmallBodyNavEKF.Q must be a %d-by-%d matrix.", stateSize, stateSize); + } + if (this->R.rows() != stateSize || this->R.cols() != stateSize) { + bskLogger.bskError("SmallBodyNavEKF.R must be a %d-by-%d matrix.", stateSize, stateSize); + } +} + /*! This method is used to add a thruster to the filter. */ @@ -400,6 +419,7 @@ void SmallBodyNavEKF::computeDynamicsMatrix(const StateVector& x_hat){ */ void SmallBodyNavEKF::UpdateState(uint64_t CurrentSimNanos) { + this->validateStateDimensions(); this->readMessages(CurrentSimNanos); this->predict(CurrentSimNanos); this->checkMRPSwitching(); diff --git a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.h b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.h index 93bb350986f..8166f06888e 100644 --- a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.h +++ b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.h @@ -57,6 +57,7 @@ class SmallBodyNavEKF: public SysModel { using StateVector = Eigen::Matrix; using StateMatrix = Eigen::Matrix; + void validateStateDimensions(); //!< Reject state and covariance dimensions incompatible with the filter. void readMessages(uint64_t CurrentSimNanos); //!< Reads input messages void writeMessages(uint64_t CurrentSimNanos); //!< Writes output messages void predict(uint64_t CurrentSimNanos); //!< Prediction step of Kalman filter diff --git a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.rst b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.rst index 348da0a3f9d..50d8e196071 100644 --- a/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.rst +++ b/src/fswAlgorithms/smallBodyNavigation/smallBodyNavEKF/smallBodyNavEKF.rst @@ -95,6 +95,10 @@ The associated frame definitions may be found in the following table. Initialization ^^^^^^^^^^^^^^ +Set ``x_hat_k`` to 12 entries in the state order above. The covariance ``P_k`` +and noise matrices ``Q`` and ``R`` must each have 12 rows and 12 columns. +Invalid dimensions raise ``BasiliskError`` in Python during reset or update. + Algorithm ^^^^^^^^^^ This module employs a hybrid extended Kalman filter (EKF) to estimate the relevant states. First, :math:`\hat{\mathbf{x}}_0` diff --git a/src/pytest.ini b/src/pytest.ini index 5953427a277..b46ee63496c 100644 --- a/src/pytest.ini +++ b/src/pytest.ini @@ -8,3 +8,4 @@ markers = scenarioTest: mark a test as a tutorial scenario test. ciSkip: mark a test that should be skipped on the CI test runs docsIntegration: documentation-tool integration test run explicitly by docs CI + buildIntegration: native build regression test run explicitly by platform CI diff --git a/src/simulation/dynamics/constraintEffector/constraintDynamicEffector.cpp b/src/simulation/dynamics/constraintEffector/constraintDynamicEffector.cpp index e3928bff408..ea378e92086 100644 --- a/src/simulation/dynamics/constraintEffector/constraintDynamicEffector.cpp +++ b/src/simulation/dynamics/constraintEffector/constraintDynamicEffector.cpp @@ -439,6 +439,9 @@ void ConstraintDynamicEffector::computeForceTorque(double integTime [[maybe_unus rDot_B1N_N = *this->inertialVelocityProperty[parent1Index]; omega_B1N_B1 = *this->inertialAngVelocityProperty[parent1Index]; sigma_B1N = Eigen::MRPd(this->inertialAttitudeProperty[parent1Index]->data()); + } else { + bskLogger.bskError("ConstraintDynamicEffector: invalid parent1 type '%s'.", + this->parent1.parentType.c_str()); } if (this->parent2.parentType == "hub") { r_B2N_N = this->hubPosition[parent2Index]->stateView(); @@ -450,6 +453,9 @@ void ConstraintDynamicEffector::computeForceTorque(double integTime [[maybe_unus rDot_B2N_N = *this->inertialVelocityProperty[parent2Index]; omega_B2N_B2 = *this->inertialAngVelocityProperty[parent2Index]; sigma_B2N = Eigen::MRPd(this->inertialAttitudeProperty[parent2Index]->data()); + } else { + bskLogger.bskError("ConstraintDynamicEffector: invalid parent2 type '%s'.", + this->parent2.parentType.c_str()); } // computing direction constraint psi in the N frame diff --git a/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityNonRotatingWarning.py b/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityNonRotatingWarning.py index 3c11bc150b4..a77bc287fc9 100644 --- a/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityNonRotatingWarning.py +++ b/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityNonRotatingWarning.py @@ -29,6 +29,8 @@ # import os +import subprocess +import sys import pytest from Basilisk import __path__ @@ -139,52 +141,57 @@ def test_polyhedralDependsOnOrientation(): assert PolyhedralGravityModel().dependsOnOrientation() is True -def test_resetEmitsWarningForTesseralFieldWithoutRotation(capfd): +def _captureResetWarning(factoryName, *factoryArgs, propagate=False): + """Capture native output across both release and Debug Windows C runtimes.""" + script = f""" +import runpy +namespace = runpy.run_path({os.path.abspath(__file__)!r}) +scSim = namespace[{factoryName!r}](*{factoryArgs!r}) +scSim.InitializeSimulation() +if {propagate!r}: + scSim.ConfigureStopTime(namespace["macros"].sec2nano(60.0)) # [s] + scSim.ExecuteSimulation() +""" + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, text=True, check=True, timeout=60, # [s] + ) + return result.stdout + + +def test_resetEmitsWarningForTesseralFieldWithoutRotation(): """GravityEffector::Reset() must actually emit the BSK_WARNING when a body whose field depends on orientation (GGM03S order 4, which retains tesseral terms) has no planet-orientation message connected. This is the user-facing alert for issue #1352, so the test fails if the warning block is removed. - The warning is read from stdout (where ``bskLog`` writes it). It is observable - here only because ``bskLog`` flushes warning-level output; without the flush - the C runtime buffers it past pytest's capture point. - Capturing the warning also exercises the full reset path, and propagating a few steps afterwards confirms the non-rotating field still initializes and runs (it must warn, not fail).""" - scSim = _buildShSim(GGM03S, 4) - scSim.InitializeSimulation() - out, _ = capfd.readouterr() + out = _captureResetWarning("_buildShSim", GGM03S, 4, propagate=True) assert WARNING_SUBSTR in out, ( "Reset() did not emit the missing-planet-rotation warning; captured " f"stdout was:\n{out}" ) - scSim.ConfigureStopTime(macros.sec2nano(60.0)) - scSim.ExecuteSimulation() - -def test_resetEmitsWarningForPolyhedralWithoutRotation(capfd): +def test_resetEmitsWarningForPolyhedralWithoutRotation(): """GravityEffector::Reset() must also emit the warning for a polyhedral body without a planet-orientation message. This is the case schaubh flagged: a polyhedral field is evaluated in the body-fixed frame and was previously falling back to non-rotating silently (issue #1352). Init-only to stay fast.""" - scSim = _buildPolySim() - scSim.InitializeSimulation() - out, _ = capfd.readouterr() + out = _captureResetWarning("_buildPolySim") assert WARNING_SUBSTR in out, ( "Reset() did not emit the missing-planet-rotation warning for a polyhedral " f"body; captured stdout was:\n{out}" ) -def test_resetSilentForZonalFieldWithoutRotation(capfd): +def test_resetSilentForZonalFieldWithoutRotation(): """A purely zonal field (GGM03S J2-only) does not depend on orientation, so Reset() must NOT warn even without a planet-orientation message. This guards against a false alarm on the common J2-only configuration (issue #1352).""" - scSim = _buildShSim(GGM03S_J2, 2) - scSim.InitializeSimulation() - out, _ = capfd.readouterr() + out = _captureResetWarning("_buildShSim", GGM03S_J2, 2) assert WARNING_SUBSTR not in out, ( "Reset() warned about a purely zonal (orientation-independent) field; " f"captured stdout was:\n{out}" diff --git a/src/simulation/sensors/hingedRigidBodyMotorSensor/_UnitTest/test_hingedRigidBodyMotorSensor.py b/src/simulation/sensors/hingedRigidBodyMotorSensor/_UnitTest/test_hingedRigidBodyMotorSensor.py index 441548516e4..0150d905309 100644 --- a/src/simulation/sensors/hingedRigidBodyMotorSensor/_UnitTest/test_hingedRigidBodyMotorSensor.py +++ b/src/simulation/sensors/hingedRigidBodyMotorSensor/_UnitTest/test_hingedRigidBodyMotorSensor.py @@ -1,12 +1,12 @@ -# +# # ISC License -# +# # Copyright (c) 2022, Autonomous Vehicle Systems Lab, University of Colorado 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 @@ -14,8 +14,8 @@ # 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 matplotlib.pyplot as plt import numpy as np @@ -27,6 +27,28 @@ from Basilisk.utilities import unitTestSupport +@pytest.mark.parametrize("noiseStd", [(0.0, 0.0), (0.0, 0.1), (0.2, 0.0), (0.2, 0.1)]) # [rad, rad/s] +def test_noise_scales_standard_samples_with_zero_channels(noiseStd): + """Zero-noise channels preserve both output values and random-stream ordering.""" + def sample(standardDeviations): + module = hingedRigidBodyMotorSensor.HingedRigidBodyMotorSensor() + module.thetaNoiseStd, module.thetaDotNoiseStd = standardDeviations + module.setRNGSeed(3) + inputMessage = messaging.HingedRigidBodyMsg().write(messaging.HingedRigidBodyMsgPayload()) + module.hingedRigidBodyMotorSensorInMsg.subscribeTo(inputMessage) + module.Reset(0) + values = [] + for timeNanos in range(6): # [ns] + module.UpdateState(timeNanos) + output = module.hingedRigidBodyMotorSensorOutMsg.read() + values.append([output.theta, output.thetaDot]) + return np.asarray(values) + + standardSamples = sample((1.0, 1.0)) # [rad, rad/s] + assert np.any(standardSamples != 0.0) + np.testing.assert_allclose(sample(noiseStd), standardSamples * noiseStd, rtol=1e-14, atol=0.0) + + @pytest.mark.parametrize("thetaNoiseStd, thetaDotNoiseStd, accuracy", [(0.0, 0.0, 1.0e-12)]) @pytest.mark.parametrize("thetaBias, thetaDotBias",[(0,0), (-.1,.1), (.2,-.01)]) @pytest.mark.parametrize("thetaLSB, thetaDotLSB",[(-1,-1), (.1,.05), (.01,.005)]) @@ -52,7 +74,7 @@ def test_hingedRigidBodyMotorSensor(show_plots, thetaNoiseStd, thetaDotNoiseStd, accuracy (double): absolute accuracy value used in the validation tests **Description of Variables Being Tested** - + The python evaluated sensed value is compared against the module output. """ @@ -66,7 +88,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo testMessages = [] unitTaskName = "unitTask" unitProcessName = "TestProcess" - + timeStep = 0.5 totalTime = 10.0 @@ -82,7 +104,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo # Configure blank module input messages hingedRigidBodyMotorSensorInMsgData = messaging.HingedRigidBodyMsgPayload() - + # set up fake input message hingedRigidBodyMotorSensorInMsgData.theta = trueTheta; hingedRigidBodyMotorSensorInMsgData.thetaDot = trueThetaDot; @@ -95,7 +117,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo # set up output message recorder objects dataLog = module.hingedRigidBodyMotorSensorOutMsg.recorder() unitTestSim.AddModelToTask(unitTaskName, dataLog) - + # set up variables in sensor module.thetaNoiseStd = thetaNoiseStd module.thetaDotNoiseStd = thetaDotNoiseStd @@ -113,11 +135,11 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo # pull module data and make sure it is correct sensedTheta = dataLog.theta[-1] sensedThetaDot = dataLog.thetaDot[-1] - + # add bias to test values biasTheta = trueTheta+thetaBias biasThetaDot = trueThetaDot+thetaDotBias - + # discretize test values if thetaLSB > 0: discTheta = round(biasTheta/thetaLSB)*thetaLSB @@ -127,7 +149,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo discThetaDot = round(biasThetaDot/thetaDotLSB)*thetaDotLSB else: discThetaDot = biasThetaDot - + print(sensedTheta) print(sensedThetaDot) print(trueTheta) @@ -141,7 +163,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo if not unitTestSupport.isDoubleEqual(sensedThetaDot, discThetaDot, accuracy): testMessages.append("Failed thetaDot bias.") testFailCount += 1 - + # check discretization if abs(thetaLSB) > accuracy: if not unitTestSupport.isDoubleEqual(sensedTheta, discTheta, accuracy): @@ -155,7 +177,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo print("PASSED: " + module.ModelTag) else: print(testMessages) - + if show_plots: thetaVals = trueTheta*np.ones(int(totalTime/timeStep)+1) thetaDotVals = trueThetaDot*np.ones(int(totalTime/timeStep)+1) @@ -181,7 +203,7 @@ def hingedRigidBodyMotorSensorTestFunction(show_plots, thetaNoiseStd, thetaDotNo plt.close("all") return [testFailCount, "".join(testMessages)] - + if __name__ == "__main__": test_hingedRigidBodyMotorSensor( diff --git a/src/simulation/sensors/hingedRigidBodyMotorSensor/hingedRigidBodyMotorSensor.cpp b/src/simulation/sensors/hingedRigidBodyMotorSensor/hingedRigidBodyMotorSensor.cpp index 1d6af1b71ee..821e9b66cd7 100644 --- a/src/simulation/sensors/hingedRigidBodyMotorSensor/hingedRigidBodyMotorSensor.cpp +++ b/src/simulation/sensors/hingedRigidBodyMotorSensor/hingedRigidBodyMotorSensor.cpp @@ -96,13 +96,11 @@ void HingedRigidBodyMotorSensor::UpdateState(uint64_t CurrentSimNanos) trueThetaDot = hingedRigidBodyMotorSensorInMsgBuffer.thetaDot; //! apply sensor noise and bias - std::normal_distribution::param_type updateThetaPair(0.0, this->thetaNoiseStd); - std::normal_distribution::param_type updateThetaDotPair(0.0, this->thetaDotNoiseStd); - - this->rNum.param(updateThetaPair); - thetaNoise = this->rNum(this->rGen); //! sample using thetaNoiseStd - this->rNum.param(updateThetaDotPair); - thetaDotNoise = this->rNum(this->rGen); //! sample using thetaDotNoiseStd + // Scale standard-normal samples so zero noise remains valid. The normal + // distribution requires a strictly positive standard deviation. Sampling + // both channels also preserves the generator sequence when noise is disabled. + thetaNoise = this->thetaNoiseStd * this->rNum(this->rGen); + thetaDotNoise = this->thetaDotNoiseStd * this->rNum(this->rGen); sensedTheta = trueTheta + thetaNoise + this->thetaBias; sensedThetaDot = trueThetaDot + thetaDotNoise + this->thetaDotBias; diff --git a/src/tests/test_cmake_message_cleanup.py b/src/tests/test_cmake_message_cleanup.py index 978cb2a0159..559f4431c63 100644 --- a/src/tests/test_cmake_message_cleanup.py +++ b/src/tests/test_cmake_message_cleanup.py @@ -14,7 +14,7 @@ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -"""Exercise generated C-message retirement through the real CMake generator.""" +"""Exercise C-message generation, native builds, and retirement through CMake.""" import os import shutil @@ -37,6 +37,11 @@ GENERATORS = [None] if os.name == "nt" else ["Unix Makefiles"] if NINJA and os.name != "nt": GENERATORS.append("Ninja") +BUILD_CONFIGURATIONS = [(generator, "Release") for generator in GENERATORS] +if NINJA and os.name != "nt": + BUILD_CONFIGURATIONS.extend( + ("Ninja Multi-Config", configuration) for configuration in ("Release", "Debug") + ) def _run(command): @@ -48,6 +53,57 @@ def _run(command): assert result.returncode == 0, result.stdout + result.stderr +@pytest.mark.ciSkip +@pytest.mark.buildIntegration +@pytest.mark.skipif(CMAKE is None, reason="CMake is required") +@pytest.mark.parametrize("generator,configuration", BUILD_CONFIGURATIONS) +def test_configured_c_messages_build_after_native_clean(tmp_path, generator, configuration): + """Compile configured interfaces before and after the native clean target. + + :param tmp_path: Temporary directory supplied by pytest. + :param generator: Native build generator to exercise. + :param configuration: Configuration selected for compilation and cleaning. + """ + source = REPOSITORY_ROOT / "src" + project = tmp_path / "project" + project.mkdir() + (tmp_path / "LICENSE").write_text("Test fixture license.\n", encoding="utf-8") + (project / "CMakeLists.txt").write_text( + f"""cmake_minimum_required(VERSION 3.26) +project(messageBuild CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +include("{source.as_posix()}/cmake/bskSourceInventory.cmake") +set(BSK_HEADER_FILES "{source.as_posix()}/architecture/msgPayloadDefC/AttRefMsgPayload.h") +add_subdirectory("{source.as_posix()}/architecture/messaging/cMsgCInterface" c-messages) +target_include_directories(cMsgCInterface PRIVATE "{source.as_posix()}") +get_target_property(message_folder cMsgCInterface FOLDER) +# IDE folders are logical hierarchy names, never absolute filesystem paths. +if(IS_ABSOLUTE "${{message_folder}}" OR message_folder MATCHES ":") + message(FATAL_ERROR "Invalid C-message IDE folder: ${{message_folder}}") +endif() +""", + encoding="utf-8", + ) + build = tmp_path / "build" + configure = [CMAKE, "-S", str(project), "-B", str(build), f"-DCMAKE_BUILD_TYPE={configuration}"] + if generator: + configure.extend(["-G", generator]) + if generator and generator.startswith("Ninja"): + configure.append(f"-DCMAKE_MAKE_PROGRAM={NINJA}") + if sys.platform == "darwin" and not os.environ.get("SDKROOT"): + configure.append("-DCMAKE_OSX_SYSROOT=macosx") + _run(configure) + command = [CMAKE, "--build", str(build), "--config", configuration] + interfaces = [build / "autoSource/cMsgCInterface" / f"AttRefMsg_C.{suffix}" for suffix in ("h", "cpp")] + original_times = {path: path.stat().st_mtime_ns for path in interfaces} + _run(command) + _run(command + ["--target", "clean"]) + assert {path: path.stat().st_mtime_ns for path in interfaces} == original_times + _run(command) + assert {path: path.stat().st_mtime_ns for path in interfaces} == original_times + + @pytest.mark.skipif(CMAKE is None, reason="CMake is required") @pytest.mark.parametrize("change", ["delete", "rename", "move_to_cpp"]) @pytest.mark.parametrize("generator", GENERATORS) diff --git a/src/tests/test_combinedFswBindings.py b/src/tests/test_combinedFswBindings.py new file mode 100644 index 00000000000..82a1d112b98 --- /dev/null +++ b/src/tests/test_combinedFswBindings.py @@ -0,0 +1,223 @@ +# 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. + +"""Check compatibility and import isolation of the combined FSW layout.""" + +import importlib +import importlib.machinery +import importlib.util +from pathlib import Path +import subprocess +import sys +import textwrap + +import pytest +from Basilisk import fswAlgorithms + + +PACKAGE_DIR = Path(fswAlgorithms.__file__).parent +COMBINED = importlib.util.find_spec("Basilisk.fswAlgorithms._fswCoreNative") + + +def test_fsw_bindings_share_library_and_keep_module_identity(): + """Every generated shim resolves to its original native module identity.""" + assert COMBINED is not None, "The core FSW native library is required" + shims = [path for path in PACKAGE_DIR.glob("_*.py") + if "Generated by generateFswBindings.py" in path.read_text()] + assert len(shims) > 1 + for shim in shims: + name = shim.stem[1:] + proxy = importlib.import_module(f"Basilisk.fswAlgorithms.{name}") + native = importlib.import_module(f"Basilisk.fswAlgorithms._{name}") + assert importlib.reload(native) is native + assert getattr(proxy, f"_{name}") is native + assert getattr(fswAlgorithms, f"_{name}") is native + assert native.__spec__.name == f"Basilisk.fswAlgorithms._{name}" + assert Path(native.__file__) == Path(COMBINED.origin) + assert isinstance(native.__loader__, importlib.machinery.ExtensionFileLoader) + assert not any((PACKAGE_DIR / f"_{name}{suffix}").exists() + for suffix in importlib.machinery.EXTENSION_SUFFIXES) + for name in ("centerRadiusCNN", "houghCircles", "limbFinding", "mrpPDRust"): + spec = importlib.util.find_spec(f"Basilisk.fswAlgorithms._{name}") + if spec is not None: + assert Path(spec.origin) != Path(COMBINED.origin) + + +@pytest.mark.parametrize("private_first", [False, True]) +def test_fsw_imports_are_on_demand_and_recorders_work(private_first): + """Public and private imports and reloads preserve identity and recorder types. + + :param private_first: Import the private extension before its public proxy. + """ + code = textwrap.dedent(f"""\ + import importlib + import importlib.machinery + import sys + from Basilisk import fswAlgorithms + assert 'Basilisk.fswAlgorithms._mrpFeedback' not in sys.modules + if {private_first}: + importlib.import_module('Basilisk.fswAlgorithms._mrpFeedback') + from Basilisk.fswAlgorithms import mrpFeedback + from Basilisk.architecture import messaging + native = importlib.import_module('Basilisk.fswAlgorithms._mrpFeedback') + assert mrpFeedback._mrpFeedback is native + assert fswAlgorithms._mrpFeedback is native + assert 'Basilisk.fswAlgorithms._mrpPD' not in sys.modules + assert 'Basilisk.fswAlgorithms.mrpPD' not in sys.modules + native_spec = native.__spec__ + sentinel = object() + native.reload_sentinel = sentinel + for _ in range(2): + assert importlib.reload(native) is native + assert importlib.import_module(native.__name__) is native + assert mrpFeedback._mrpFeedback is native + assert fswAlgorithms._mrpFeedback is native + assert native.__file__ == native_spec.origin + assert isinstance(native.__loader__, importlib.machinery.ExtensionFileLoader) + assert native.__spec__.origin == native_spec.origin + assert native.__package__ == native_spec.parent + assert not hasattr(native, '__cached__') + assert native.reload_sentinel is sentinel + model = mrpFeedback.mrpFeedback() + recorder = model.cmdTorqueOutMsg.recorder() + assert isinstance(recorder, messaging.CmdTorqueBodyMsgRecorder) + payload = messaging.CmdTorqueBodyMsgPayload() + payload.torqueRequestBody = [1.0, 2.0, 3.0] # [N m] + write_time = 42 # [ns] + model.cmdTorqueOutMsg.write(payload, write_time) + recorder.UpdateState(write_time) + assert recorder.times().tolist() == [write_time] + assert recorder.torqueRequestBody.tolist() == [[1.0, 2.0, 3.0]] # [N m] + importlib.reload(mrpFeedback) + assert mrpFeedback._mrpFeedback is native + assert importlib.import_module(native.__name__) is native + """) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("second_import", ["private", "public"]) +@pytest.mark.parametrize("fail_first", [False, True], ids=["success", "retry"]) +def test_concurrent_fsw_imports_wait_for_native_initialization(second_import, fail_first): + """Concurrent imports wait for initialization and can retry a failed import. + + :param second_import: Import the private extension or its public proxy second. + :param fail_first: Fail the first initialization and verify a subsequent import recovers. + """ + code = textwrap.dedent("""\ + import importlib + import importlib.machinery + import sys + import threading + from Basilisk import fswAlgorithms + + native_name = 'Basilisk.fswAlgorithms._mrpFeedback' + public_name = 'Basilisk.fswAlgorithms.mrpFeedback' + second_name = native_name if sys.argv[1] == 'private' else public_name + fail_first = sys.argv[2] == 'True' + timeout = 10.0 # [s] + blocking_window = 0.2 # [s] + entered = threading.Event() + release = threading.Event() + second_started = threading.Event() + second_done = threading.Event() + modules = {} + errors = {} + attempts = [] + original_exec = importlib.machinery.ExtensionFileLoader.exec_module + + def paused_exec(loader, module): + if loader.name == native_name: + attempts.append(module) + if len(attempts) == 1: + entered.set() + assert release.wait(timeout), 'Native initialization was never released' + if fail_first: + raise RuntimeError('Injected native initialization failure') + return original_exec(loader, module) + + def import_module(label, name): + if label == 'second': + second_started.set() + try: + modules[label] = importlib.import_module(name) + except BaseException as error: + errors[label] = error + finally: + if label == 'second': + second_done.set() + + importlib.machinery.ExtensionFileLoader.exec_module = paused_exec + first = threading.Thread(target=import_module, args=('first', native_name), daemon=True) + second = threading.Thread(target=import_module, args=('second', second_name), daemon=True) + try: + first.start() + assert entered.wait(timeout), 'Native initialization did not start' + second.start() + assert second_started.wait(timeout), 'Concurrent import did not start' + returned_early = second_done.wait(blocking_window) + finally: + release.set() + first.join(timeout) + if second.ident is not None: + second.join(timeout) + importlib.machinery.ExtensionFileLoader.exec_module = original_exec + + assert not first.is_alive() and not second.is_alive(), 'Import deadlock' + assert not returned_early, 'Concurrent import returned an unfinished native module' + assert second_done.is_set() + if fail_first: + assert isinstance(errors.get('first'), RuntimeError), errors + assert str(errors['first']) == 'Injected native initialization failure' + if 'second' in errors: + # Some Python versions report ImportError to a waiting public + # import after the private import fails. Require a clean retry. + assert second_name == public_name + assert isinstance(errors['second'], ImportError), errors + assert len(attempts) == 1 + assert native_name not in sys.modules + assert public_name not in sys.modules + importlib.machinery.ExtensionFileLoader.exec_module = paused_exec + try: + modules['second'] = importlib.import_module(second_name) + finally: + importlib.machinery.ExtensionFileLoader.exec_module = original_exec + del errors['second'] + assert set(errors) == {'first'}, errors + assert len(attempts) == 2 + else: + assert not errors, errors + assert len(attempts) == 1 + + native = importlib.import_module(native_name) + proxy = importlib.import_module(public_name) + assert modules['second'] is (native if second_name == native_name else proxy) + assert native is attempts[-1] + if not fail_first: + assert modules['first'] is native + assert proxy._mrpFeedback is fswAlgorithms._mrpFeedback is native + assert all(not getattr(module.__spec__, '_initializing', False) for module in attempts) + assert importlib.reload(native) is native + from Basilisk.architecture import messaging + model = proxy.mrpFeedback() + assert isinstance(model.cmdTorqueOutMsg.recorder(), messaging.CmdTorqueBodyMsgRecorder) + """) + subprocess_timeout = 60 # [s] + result = subprocess.run( + [sys.executable, "-c", code, second_import, str(fail_first)], + capture_output=True, text=True, timeout=subprocess_timeout, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/src/tests/test_combinedSimulationBindings.py b/src/tests/test_combinedSimulationBindings.py new file mode 100644 index 00000000000..99c6c776528 --- /dev/null +++ b/src/tests/test_combinedSimulationBindings.py @@ -0,0 +1,137 @@ +# 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. + +"""Check grouped simulation and optional imports without changing their public APIs.""" + +import importlib +import importlib.machinery +import importlib.util +from pathlib import Path +import re +import subprocess +import sys + +import pytest +from Basilisk import fswAlgorithms, hasBuildFeature, simulation + + +GROUPS = ( + ("Basilisk.simulation._simulationCoreNative", "simulation", "spacecraft", "Spacecraft"), + pytest.param("Basilisk.simulation._mujocoNative", "simulation", "thrOnTimeToForce", "ThrOnTimeToForce", + marks=pytest.mark.skipif(not hasBuildFeature("mujoco"), reason="MuJoCo support is disabled")), + pytest.param("Basilisk._opNavNative", "simulation", "camera", "Camera", + marks=pytest.mark.skipif(not hasBuildFeature("opNav"), reason="OpenCV support is disabled")), +) + + +@pytest.mark.parametrize("library,package,name,class_name", GROUPS) +def test_grouped_imports_preserve_proxies_and_private_modules(library, package, name, class_name): + """Every grouped private extension retains its original Python proxy and reload identity. + + :param library: Name of the optional native container. + :param package: Package containing the representative binding. + :param name: Representative public module name. + :param class_name: Representative constructor name. + """ + spec = importlib.util.find_spec(library) + assert spec is not None, f"Required native binding group is missing: {library}" + count = 0 + for parent in (fswAlgorithms, simulation): + directory = Path(parent.__file__).parent + proxies = {} + for path in directory.glob("*.py"): + text = path.read_text(encoding="utf-8") + match = re.search(r"^ from \. import (_[A-Za-z_][A-Za-z_0-9]*)$", text, re.MULTILINE) + if match: + proxies[match[1]] = path.stem + for shim in directory.glob("_*.py"): + text = shim.read_text(encoding="utf-8") + if "Generated by generateGroupedBindings.py" not in text or repr(library) not in text: + continue + native = importlib.import_module(parent.__name__ + "." + shim.stem) + proxy = importlib.import_module(parent.__name__ + "." + proxies[shim.stem]) + assert getattr(proxy, shim.stem) is native + assert importlib.reload(native) is native + assert getattr(parent, shim.stem) is native + assert native.__file__ == spec.origin + count += 1 + assert count > 1 + proxy = importlib.import_module("Basilisk." + package + "." + name) + assert getattr(proxy, class_name)() is not None + + +@pytest.mark.parametrize("library,package,name,class_name", GROUPS) +def test_grouped_imports_initialize_only_requested_bindings(library, package, name, class_name): + """A representative import leaves unrelated native bindings uninitialized. + + :param library: Name of the optional native container. + :param package: Package containing the representative binding. + :param name: Representative public module name. + :param class_name: Representative constructor name. + """ + assert importlib.util.find_spec(library) is not None, f"Required native binding group is missing: {library}" + code = """import importlib, sys +from Basilisk import architecture +module = importlib.import_module(sys.argv[1]) +assert getattr(module, sys.argv[2])() is not None +for unrelated in ('Basilisk.simulation._solarFlux', 'Basilisk.simulation._MJSystemCoM', + 'Basilisk.fswAlgorithms._houghCircles'): + assert unrelated not in sys.modules, unrelated +""" + timeout = 60 # [s] + result = subprocess.run([sys.executable, "-c", code, "Basilisk." + package + "." + name, class_name], + capture_output=True, text=True, timeout=timeout) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("suffix", importlib.machinery.EXTENSION_SUFFIXES) +def test_group_layout_cleanup_preserves_other_groups(tmp_path, suffix): + """Switching a cross-package group leaves adjacent native modules and loaders intact. + + :param tmp_path: Temporary directory supplied by pytest. + :param suffix: Native extension suffix supported by the active Python interpreter. + """ + source = Path(__file__).resolve().parents[1] + spec = importlib.util.spec_from_file_location("generateGroupedBindings", source / "cmake/generateGroupedBindings.py") + generator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(generator) + manifest = tmp_path / "group.txt" + package = tmp_path / "Basilisk" + manifest.write_text("simulation.first\nfswAlgorithms.second\n") + loader = source / "fswAlgorithms/_load_fsw.py" + (package / "simulation").mkdir(parents=True) + for name in ("first.lib", "_first.lib"): + (package / "simulation" / name).touch() + generator.generate_layout(manifest, package, loader, "_opNavNative", "_load_opnav", manifest) + assert not list((package / "simulation").glob("*.lib")) + first = package / "simulation/_first.py" + stamp = first.stat().st_mtime_ns + generator.generate_layout(manifest, package, loader, "_opNavNative", "_load_opnav", manifest) + assert first.stat().st_mtime_ns == stamp + unrelated = package / f"simulation/_simulationCoreNative{suffix}" + unrelated.write_bytes(b"other group") + native = package / f"_opNavNative{suffix}" + native.touch() + archive = package / "_opNavNative.lib" + archive.touch() + manifest.write_text("") + generator.generate_layout(manifest, package, loader, "_opNavNative", "_load_opnav", manifest) + assert not first.exists() + assert not (package / "fswAlgorithms/_second.py").exists() + assert not (package / "_load_opnav.py").exists() + assert not native.exists() + assert not archive.exists() + assert unrelated.read_bytes() == b"other group" diff --git a/src/tests/test_fswBindingLayout.py b/src/tests/test_fswBindingLayout.py new file mode 100644 index 00000000000..7243d5c61d8 --- /dev/null +++ b/src/tests/test_fswBindingLayout.py @@ -0,0 +1,541 @@ +# 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. + +"""Check native library creation and cleanup for the combined binding layouts.""" + +from concurrent.futures import ThreadPoolExecutor +import importlib.machinery +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time + +import pytest + + +SOURCE = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "generateFswBindings", SOURCE / "cmake/generateFswBindings.py") +GENERATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(GENERATOR) +SUBPROCESS_TIMEOUT_SECONDS = 180 # [s] + + +def find_build_tool(name): + """Find a build tool on PATH or beside the active Python executable.""" + executable = name + (".exe" if os.name == "nt" else "") + local = Path(sys.executable).with_name(executable) + return shutil.which(executable) or (str(local) if local.is_file() else None) + + +CMAKE = find_build_tool("cmake") +NINJA = find_build_tool("ninja") +SWIG = find_build_tool("swig") +BUILD_GENERATORS = [None] if os.name == "nt" else ["Unix Makefiles"] +if NINJA and (os.name != "nt" or os.environ.get("VCINSTALLDIR")): + BUILD_GENERATORS.append("Ninja") +if sys.platform == "darwin": + BUILD_GENERATORS.append("Xcode") + +# Exercise the full compiler/API matrix once, plus one representative real SWIG +# build with each other generator to retain coverage of its generated build graph. +DIRECTOR_GENERATOR = "Ninja" if "Ninja" in BUILD_GENERATORS else BUILD_GENERATORS[0] +DIRECTOR_CONFIGURATIONS = [ + pytest.param(generator, limited_api, threads, + id=f"{generator}-{'limited' if limited_api else 'full'}-api-" + f"{'threads' if threads else 'no-threads'}") + for generator in BUILD_GENERATORS + for limited_api in (False, True) + for threads in (False, True) + if generator == DIRECTOR_GENERATOR or (limited_api and threads) +] + + +@pytest.mark.parametrize("update_order", ["source-first", "destination-first", "parallel"]) +@pytest.mark.parametrize("source_group,destination_group,package_name", [ + ("simulationCoreNative", "mujocoNative", "simulation"), + ("mujocoNative", "simulationCoreNative", "simulation"), + ("simulationCoreNative", "opNavNative", "simulation"), + ("opNavNative", "simulationCoreNative", "simulation"), + ("mujocoNative", "opNavNative", "simulation"), + ("opNavNative", "mujocoNative", "simulation"), + ("fswCoreNative", "opNavNative", "fswAlgorithms"), + ("opNavNative", "fswCoreNative", "fswAlgorithms"), +]) +def test_moving_bindings_preserves_destination_shims( + tmp_path, source_group, destination_group, package_name, update_order, +): + """Moving bindings preserves their new shims regardless of layout update order. + + :param tmp_path: Temporary directory supplied by pytest. + :param source_group: Group that previously owned the moving binding. + :param destination_group: Group that will own the moving binding. + :param package_name: Python package containing the binding. + :param update_order: Order in which the two layout generators run. + """ + spec = importlib.util.spec_from_file_location( + "generateGroupedBindings", SOURCE / "cmake/generateGroupedBindings.py") + grouped_generator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(grouped_generator) + root = tmp_path / "Basilisk" + package = root / package_name + loader = SOURCE / "fswAlgorithms/_load_fsw.py" + manifests = {group: tmp_path / f"{group}.txt" for group in (source_group, destination_group)} + active_manifest = tmp_path / "combinedBindingModules.txt" + + def write_manifest(group, names): + """Write the group's own manifest format.""" + prefix = "" if group == "fswCoreNative" else package_name + "." + manifests[group].write_text("".join(prefix + name + "\n" for name in names), encoding="utf-8") + + def generate(group): + """Run the real generator for one group's layout.""" + if group == "fswCoreNative": + GENERATOR.generate_layout(manifests[group], package, loader, active_manifest) + else: + native = f"_{group}" if group == "opNavNative" else f"simulation._{group}" + loader_name = f"_load_{group}" + grouped_generator.generate_layout(manifests[group], root, loader, native, loader_name, active_manifest) + + write_manifest(source_group, ["moving", "retired"]) + write_manifest(destination_group, []) + active_manifest.write_text(f"{package_name}.moving\n{package_name}.retired\n", encoding="utf-8") + generate(source_group) + generate(destination_group) + write_manifest(source_group, []) + write_manifest(destination_group, ["moving", "added"]) + active_manifest.write_text(f"{package_name}.moving\n{package_name}.added\n", encoding="utf-8") + if update_order == "parallel": + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(generate, (source_group, destination_group))) + else: + order = (source_group, destination_group) + if update_order == "destination-first": + order = tuple(reversed(order)) + for group in order: + generate(group) + + moving = package / "_moving.py" + assert moving.is_file(), "The former group deleted the destination group's shim" + expected_loader = ("from ._load_fsw" if destination_group == "fswCoreNative" + else f"from Basilisk._load_{destination_group}") + assert expected_loader in moving.read_text(encoding="utf-8") + assert (package / "_added.py").is_file() + assert not (package / "_retired.py").exists() + original_mtime = moving.stat().st_mtime_ns + generate(source_group) + generate(destination_group) + assert moving.stat().st_mtime_ns == original_mtime + write_manifest(destination_group, []) + active_manifest.write_text("", encoding="utf-8") + generate(destination_group) + assert not moving.exists() + assert not (package / "_added.py").exists() + + +def test_layout_switch_preserves_optional_bindings_and_retires_stale_files(tmp_path): + """A changed group replaces stale binaries and removes only its old shims. + + :param tmp_path: Temporary directory supplied by pytest. + """ + package = tmp_path / "fswAlgorithms" + package.mkdir() + manifest = tmp_path / "fswCoreBindings.txt" + active_manifest = tmp_path / "combinedBindingModules.txt" + loader = SOURCE / "fswAlgorithms/_load_fsw.py" + suffix = importlib.machinery.EXTENSION_SUFFIXES[0] + optional = package / f"_optional{suffix}" + optional.write_bytes(b"optional native binding") + for name in ("first", "second"): + (package / f"_{name}{suffix}").touch() + manifest.write_text("first\nsecond\n") + active_manifest.write_text("fswAlgorithms.first\nfswAlgorithms.second\n", encoding="utf-8") + GENERATOR.generate_layout(manifest, package, loader, active_manifest) + assert not (package / f"_first{suffix}").exists() + assert not (package / f"_second{suffix}").exists() + first = package / "_first.py" + first_time = first.stat().st_mtime_ns + GENERATOR.generate_layout(manifest, package, loader, active_manifest) + assert first.stat().st_mtime_ns == first_time + manifest.write_text("first\n") + active_manifest.write_text("fswAlgorithms.first\n", encoding="utf-8") + GENERATOR.generate_layout(manifest, package, loader, active_manifest) + assert first.exists() + assert not (package / "_second.py").exists() + combined = package / f"_fswCoreNative{suffix}" + combined.touch() + manifest.write_text("") + active_manifest.write_text("", encoding="utf-8") + GENERATOR.generate_layout(manifest, package, loader, active_manifest) + assert not first.exists() + assert not combined.exists() + assert not (package / "_load_fsw.py").exists() + assert optional.read_bytes() == b"optional native binding" + + +@pytest.mark.ciSkip +@pytest.mark.buildIntegration +@pytest.mark.skipif(CMAKE is None, reason="CMake is required") +@pytest.mark.parametrize("build_generator", BUILD_GENERATORS) +def test_cmake_binding_moves_preserve_shims_after_reconfiguration(tmp_path, build_generator): + """CMake protects moved shims and still removes bindings retired from all groups. + + :param tmp_path: Temporary directory supplied by pytest. + :param build_generator: CMake generator used for the isolated layout build. + """ + if build_generator == "Xcode": + xcodebuild = shutil.which("xcodebuild") + if xcodebuild is None: + pytest.skip("Xcode is not installed") + version = subprocess.run([xcodebuild, "-version"], capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS) + if version.returncode: + pytest.skip("A full Xcode installation must be selected to test its generator") + + project = tmp_path / "source" + build = tmp_path / "build" + (project / "cmake").mkdir(parents=True) + (project / "fswAlgorithms").mkdir() + for name in ("generateFswBindings.py", "generateGroupedBindings.py"): + shutil.copyfile(SOURCE / "cmake" / name, project / "cmake" / name) + shutil.copyfile(SOURCE / "fswAlgorithms/_load_fsw.py", project / "fswAlgorithms/_load_fsw.py") + (project / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.26) +project(bindingMoveTest LANGUAGES NONE) +include("{SOURCE.as_posix()}/cmake/bskCombineFswBindings.cmake") +include("{SOURCE.as_posix()}/cmake/bskCombineSimulationBindings.cmake") +if(MOVE_BINDINGS) + set_property(GLOBAL PROPERTY BSK_mujocoNative_MODULES simulation.moving) + set_property(GLOBAL PROPERTY BSK_opNavNative_MODULES fswAlgorithms.moving) +else() + set_property(GLOBAL PROPERTY BSK_FSW_COMBINED_MODULES moving retired) + set_property(GLOBAL PROPERTY BSK_simulationCoreNative_MODULES simulation.moving simulation.retired) +endif() +bsk_finalize_fsw_bindings() +bsk_finalize_simulation_bindings() +add_custom_target(sourceLayouts DEPENDS fswBindingsLayout simulationCoreNativeLayout) +add_custom_target(destinationLayouts DEPENDS opNavNativeLayout mujocoNativeLayout) +add_custom_target(allLayouts DEPENDS sourceLayouts destinationLayouts) +''', encoding="utf-8") + + def run(command): + """Run a CMake command and include its output when it fails.""" + result = subprocess.run(command, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SECONDS) + assert result.returncode == 0, result.stdout + result.stderr + + def prepare_reconfiguration(): + """Separate timestamps for Make versions with whole-second resolution.""" + if build_generator == "Unix Makefiles": + timestamp = time.time_ns() - 2_000_000_000 # [ns] + # Age both inputs and outputs so only changed manifests trigger work. + # This models a later reconfiguration without sleeping in the test. + for directory in (build / "autoSource", build / "Basilisk", + project / "cmake", project / "fswAlgorithms"): + for path in directory.rglob("*"): + if path.is_file(): + os.utime(path, ns=(timestamp, timestamp)) + + configure = [CMAKE, "-S", str(project), "-B", str(build), f"-DPython3_EXECUTABLE={sys.executable}"] + if build_generator: + configure.extend(["-G", build_generator]) + if build_generator == "Ninja": + configure.append(f"-DCMAKE_MAKE_PROGRAM={NINJA}") + build_command = [CMAKE, "--build", str(build), "--config", "Release", "--parallel", "4", "--target"] + run(configure + ["-DMOVE_BINDINGS=OFF"]) + run(build_command + ["allLayouts"]) + prepare_reconfiguration() + run(configure + ["-DMOVE_BINDINGS=ON"]) + run(build_command + ["destinationLayouts"]) + shims = [build / "Basilisk" / package / "_moving.py" for package in ("fswAlgorithms", "simulation")] + destination_text = [shim.read_text(encoding="utf-8") for shim in shims] + assert "Basilisk._opNavNative" in destination_text[0] + assert "Basilisk.simulation._mujocoNative" in destination_text[1] + run(build_command + ["sourceLayouts"]) + assert [shim.read_text(encoding="utf-8") for shim in shims] == destination_text + assert not any(shim.with_name("_retired.py").exists() for shim in shims) + + timestamps = [shim.stat().st_mtime_ns for shim in shims] + run(configure + ["-DMOVE_BINDINGS=ON"]) + run(build_command + ["allLayouts"]) + assert [shim.stat().st_mtime_ns for shim in shims] == timestamps + # Move back with the previous owners' cleanup running last again. + prepare_reconfiguration() + run(configure + ["-DMOVE_BINDINGS=OFF"]) + run(build_command + ["sourceLayouts"]) + run(build_command + ["destinationLayouts"]) + assert "from ._load_fsw" in shims[0].read_text(encoding="utf-8") + assert "Basilisk.simulation._simulationCoreNative" in shims[1].read_text(encoding="utf-8") + + +@pytest.mark.ciSkip +@pytest.mark.buildIntegration +@pytest.mark.skipif(CMAKE is None, reason="CMake is required") +@pytest.mark.parametrize("build_generator", BUILD_GENERATORS) +def test_combined_library_is_linked_and_reconfigure_is_incremental(tmp_path, build_generator): + """Build all four groups together and verify their imports and unchanged rebuild. + + :param tmp_path: Temporary directory supplied by pytest. + :param build_generator: CMake generator used for the isolated native build. + """ + if build_generator == "Xcode": + xcodebuild = shutil.which("xcodebuild") + if xcodebuild is None: + pytest.skip("Xcode is not installed") + version = subprocess.run([xcodebuild, "-version"], capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS) + if version.returncode: + pytest.skip("A full Xcode installation must be selected to test its generator") + + project = tmp_path / "source" + build = tmp_path / "build" + (project / "cmake").mkdir(parents=True) + (project / "fswAlgorithms").mkdir() + shutil.copyfile(SOURCE / "cmake/generateFswBindings.py", project / "cmake/generateFswBindings.py") + shutil.copyfile(SOURCE / "cmake/generateGroupedBindings.py", project / "cmake/generateGroupedBindings.py") + shutil.copyfile(SOURCE / "fswAlgorithms/_load_fsw.py", project / "fswAlgorithms/_load_fsw.py") + declarations = [f'''cmake_minimum_required(VERSION 3.26) +project(bindingBuildTest LANGUAGES CXX) +find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) +include("{SOURCE.as_posix()}/cmake/bskCombineFswBindings.cmake") +include("{SOURCE.as_posix()}/cmake/bskCombineSimulationBindings.cmake") +'''] + groups = ("fswCoreNative", "simulationCoreNative", "mujocoNative", "opNavNative") + libraries = [] + probe = ["import importlib, sys", "sys.path.insert(0, sys.argv[1])", "native_files = set()"] + suffix = ".pyd" if os.name == "nt" else ".so" + # Share compiler discovery and generator startup, retaining two native + # entry points per group and opNav imports from both public packages. + for group in groups: + packages = (("fswAlgorithms", "fswAlgorithms") if group == "fswCoreNative" else + ("simulation", "fswAlgorithms" if group == "opNavNative" else "simulation")) + output_package = "" if group == "opNavNative" else packages[0] + libraries.append(build / "Basilisk" / output_package / f"_{group}{suffix}") + probe.append("modules = []") + for value, package in enumerate(packages, start=1): + name = f"{group}{value}" + (project / f"{name}.cpp").write_text( + f"""#include +static PyObject *value(PyObject *, PyObject *) {{ return PyLong_FromLong({value}); }} +static PyMethodDef methods[] = {{ + {{"value", value, METH_NOARGS, nullptr}}, {{nullptr, nullptr, 0, nullptr}} +}}; +static PyModuleDef module = {{PyModuleDef_HEAD_INIT, "_{name}", nullptr, -1, methods}}; +PyMODINIT_FUNC PyInit__{name}(void) {{ return PyModule_Create(&module); }} +""", encoding="utf-8", + ) + declarations.append(f'''add_library({name}Objects OBJECT {name}.cpp) +set_target_properties({name}Objects PROPERTIES POSITION_INDEPENDENT_CODE ON CXX_STANDARD 17) +target_link_libraries({name}Objects PRIVATE Python3::Module) +''') + if group == "fswCoreNative": + declarations.append(f'''set_property(GLOBAL APPEND PROPERTY BSK_FSW_OBJECT_TARGETS {name}Objects) +set_property(GLOBAL APPEND PROPERTY BSK_FSW_COMBINED_MODULES {name}) +set_property(GLOBAL APPEND PROPERTY BSK_FSW_BINDING_TARGETS {name}Objects) +''') + else: + declarations.append(f'''set_property(GLOBAL APPEND PROPERTY BSK_{group}_OBJECT_TARGETS {name}Objects) +set_property(GLOBAL APPEND PROPERTY BSK_{group}_MODULES {package}.{name}) +set_property(GLOBAL APPEND PROPERTY BSK_SIMULATION_BINDING_TARGETS {name}Objects) +''') + probe.extend([ + f"module = importlib.import_module('Basilisk.{package}._{name}')", + f"assert module.value() == {value}", + "modules.append(module)", + ]) + probe.extend([ + "assert modules[0].__file__ == modules[1].__file__", + f"assert modules[0].__file__.endswith('_{group}{suffix}')", + "native_files.add(modules[0].__file__)", + ]) + probe.append("assert len(native_files) == 4") + declarations.append('''bsk_finalize_fsw_bindings() +bsk_finalize_simulation_bindings() +add_custom_target(allBindings DEPENDS fswCoreNative simulationCoreNative mujocoNative opNavNative) +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk/fswAlgorithms") +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/Basilisk/simulation") +file(WRITE "${CMAKE_BINARY_DIR}/Basilisk/__init__.py" "") +file(WRITE "${CMAKE_BINARY_DIR}/Basilisk/fswAlgorithms/__init__.py" "") +file(WRITE "${CMAKE_BINARY_DIR}/Basilisk/simulation/__init__.py" "") +''') + (project / "CMakeLists.txt").write_text("\n".join(declarations), encoding="utf-8") + + def run(command): + """Run a fixture command and include its output when it fails.""" + result = subprocess.run(command, capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS) + assert result.returncode == 0, result.stdout + result.stderr + + configure = [CMAKE, "-S", str(project), "-B", str(build), + f"-DPython3_EXECUTABLE={sys.executable}", "-DCMAKE_BUILD_TYPE=Release"] + if build_generator: + configure.extend(["-G", build_generator]) + if build_generator == "Ninja": + configure.append(f"-DCMAKE_MAKE_PROGRAM={NINJA}") + build_command = [CMAKE, "--build", str(build), "--target", "allBindings", "--config", "Release"] + run(configure) + run(build_command) + for library in libraries: + assert library.is_file(), f"The build succeeded without producing {library}" + run([sys.executable, "-I", "-c", "\n".join(probe), str(build)]) + original_mtimes = [library.stat().st_mtime_ns for library in libraries] + run(configure) + run(build_command) + assert [library.stat().st_mtime_ns for library in libraries] == original_mtimes + + +@pytest.mark.parametrize("semicolon", [";", ""], ids=["swig44", "swig45"]) +def test_director_mutex_syntax_variants(tmp_path, semicolon): + """Accept the mutex forms emitted by both supported SWIG runtime versions. + + :param tmp_path: Temporary directory supplied by pytest. + :param semicolon: Terminator emitted after the macro invocation. + """ + spec = importlib.util.spec_from_file_location( + "prepareGroupedWrapper", SOURCE / "cmake/prepareGroupedWrapper.py") + preparer = importlib.util.module_from_spec(spec) + spec.loader.exec_module(preparer) + source = tmp_path / "wrapper.cxx" + destination = tmp_path / "grouped.cxx" + definition = "SWIG_GUARD_DEFINITION(Director, swig_mutex_own)" + semicolon + source.write_text("#define SWIG_DIRECTORS\n " + definition + "\n", encoding="utf-8") + preparer.prepare_wrapper(source, destination) + assert destination.read_text(encoding="utf-8") == ( + "#define SWIG_DIRECTORS\n #ifdef SWIG_THREADS\n inline " + definition + "\n #endif\n") + for count in (0, 2): + source.write_text("#define SWIG_DIRECTORS\n" + (definition + "\n") * count, encoding="utf-8") + with pytest.raises(ValueError, match=f"found {count} definitions"): + preparer.prepare_wrapper(source, destination) + + +@pytest.mark.ciSkip +@pytest.mark.buildIntegration +@pytest.mark.skipif(CMAKE is None or SWIG is None, reason="CMake and SWIG are required") +@pytest.mark.parametrize("build_generator,limited_api,threads", DIRECTOR_CONFIGURATIONS) +def test_grouped_swig_directors_preserve_cross_module_callbacks(tmp_path, build_generator, limited_api, threads): + """Compile real SWIG wrappers and dispatch Python overrides across modules. + + :param tmp_path: Temporary directory supplied by pytest. + :param build_generator: CMake generator used for the isolated native build. + :param limited_api: Compile against the stable Python API when true. + :param threads: Generate SWIG's threaded director runtime when true. + """ + if build_generator == "Xcode": + xcodebuild = shutil.which("xcodebuild") + if xcodebuild is None: + pytest.skip("Xcode is not installed") + version = subprocess.run([xcodebuild, "-version"], capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS) + if version.returncode: + pytest.skip("A full Xcode installation must be selected to test its generator") + + project = tmp_path / "source" + build = tmp_path / "build" + (project / "cmake").mkdir(parents=True) + (project / "fswAlgorithms").mkdir() + for name in ("generateGroupedBindings.py", "prepareGroupedWrapper.py"): + shutil.copyfile(SOURCE / "cmake" / name, project / "cmake" / name) + shutil.copyfile(SOURCE / "fswAlgorithms/_load_fsw.py", project / "fswAlgorithms/_load_fsw.py") + (project / "base.h").write_text( + "#pragma once\nclass Base { public: virtual ~Base() = default; virtual int value() { return 2; } };\n", + encoding="utf-8", + ) + (project / "first.i").write_text('''%module(package="Basilisk.simulation", directors="1") first +%feature("director") Base; +%{ +#include "base.h" +%} +%include "base.h" +''', encoding="utf-8") + (project / "second.i").write_text('''%module(package="Basilisk.simulation", directors="1") second +%{ +#include "base.h" +%} +%import "first.i" +%feature("director") Other; +%inline %{ +class Other { public: virtual ~Other() = default; virtual int value() { return 3; } }; +int call(Base *object) { return object->value(); } +int callOther(Other *object) { return object->value(); } +bool isDirector(Base *object) { return dynamic_cast(object) != nullptr; } +%} +''', encoding="utf-8") + helper = SOURCE / "cmake/bskCombineSimulationBindings.cmake" + (project / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.26) +project(directorBuildTest LANGUAGES CXX) +find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) +find_package(SWIG 4.4.1 REQUIRED) +include(${{SWIG_USE_FILE}}) +set(SWIG_USE_SWIG_DEPENDENCIES ON) +set(CMAKE_CXX_STANDARD 17) +include("{helper.as_posix()}") +if(LIMITED_API) + add_compile_definitions(Py_LIMITED_API=0x03090000) +endif() +set(out "${{CMAKE_BINARY_DIR}}/Basilisk/simulation") +foreach(name first second) + set(interface "${{CMAKE_CURRENT_SOURCE_DIR}}/${{name}}.i") + set_source_files_properties("${{interface}}" PROPERTIES CPLUSPLUS ON USE_TARGET_INCLUDE_DIRECTORIES TRUE) + set_property(SOURCE "${{interface}}" APPEND PROPERTY SWIG_FLAGS -interface "_${{name}}" {"-threads" if threads else "-nothreads"}) + swig_add_library(${{name}}GroupedObjects LANGUAGE python TYPE OBJECT SOURCES "${{interface}}" + OUTFILE_DIR "${{out}}" OUTPUT_DIR "${{out}}") + target_link_libraries(${{name}}GroupedObjects PRIVATE Python3::Module) + target_include_directories(${{name}}GroupedObjects PRIVATE "${{CMAKE_CURRENT_SOURCE_DIR}}") + set_target_properties(${{name}}GroupedObjects PROPERTIES POSITION_INDEPENDENT_CODE ON) + set(wrapper "${{out}}/${{name}}PYTHON_wrap.cxx") + set(grouped "${{out}}/${{name}}PYTHON_grouped.cxx") + add_custom_command(OUTPUT "${{grouped}}" + COMMAND "${{Python3_EXECUTABLE}}" "${{CMAKE_SOURCE_DIR}}/cmake/prepareGroupedWrapper.py" "${{wrapper}}" "${{grouped}}" + DEPENDS "${{wrapper}}" "${{CMAKE_SOURCE_DIR}}/cmake/prepareGroupedWrapper.py" VERBATIM) + get_target_property(wrapper_sources ${{name}}GroupedObjects SOURCES) + list(REMOVE_ITEM wrapper_sources "${{wrapper}}") + set_property(TARGET ${{name}}GroupedObjects PROPERTY SOURCES ${{wrapper_sources}} "${{grouped}}") + set_property(GLOBAL APPEND PROPERTY BSK_mujocoNative_OBJECT_TARGETS ${{name}}GroupedObjects) + set_property(GLOBAL APPEND PROPERTY BSK_mujocoNative_MODULES "simulation.${{name}}") + set_property(GLOBAL APPEND PROPERTY BSK_SIMULATION_BINDING_TARGETS ${{name}}GroupedObjects) +endforeach() +bsk_finalize_simulation_bindings() +file(WRITE "${{CMAKE_BINARY_DIR}}/Basilisk/__init__.py" "") +file(WRITE "${{out}}/__init__.py" "") +''', encoding="utf-8") + configure = [CMAKE, "-S", str(project), "-B", str(build), + f"-DPython3_EXECUTABLE={sys.executable}", f"-DSWIG_EXECUTABLE={SWIG}", + f"-DLIMITED_API={'ON' if limited_api else 'OFF'}", "-DCMAKE_BUILD_TYPE=Release"] + if build_generator: + configure.extend(["-G", build_generator]) + if build_generator == "Ninja": + configure.append(f"-DCMAKE_MAKE_PROGRAM={NINJA}") + probe = '''import sys +sys.path.insert(0, sys.argv[1]) +from Basilisk.simulation import first, second +class First(first.Base): + def value(self): return 42 +class Second(second.Other): + def value(self): return 17 +first_object = First() +assert second.call(first_object) == 42 +assert second.callOther(Second()) == 17 +assert second.isDirector(first_object) +assert first._first.__file__ == second._second.__file__ +''' + for command in ( + configure, + [CMAKE, "--build", str(build), "--target", "mujocoNative", "--config", "Release"], + [sys.executable, "-I", "-c", probe, str(build)], + ): + result = subprocess.run(command, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SECONDS) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/src/tests/test_incremental_build_checks.py b/src/tests/test_incremental_build_checks.py new file mode 100644 index 00000000000..96dbf76f9a1 --- /dev/null +++ b/src/tests/test_incremental_build_checks.py @@ -0,0 +1,110 @@ +# 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. + +"""Exercise the incremental-build checks with native CMake generators.""" + +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import sys + +import pytest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +CHECKS = runpy.run_path(str(REPOSITORY_ROOT / ".github/scripts/test_incremental_build.py")) +SUBPROCESS_TIMEOUT_SECONDS = 180 # [s] + + +def find_build_tool(name): + """Find a build tool on PATH or beside the active Python executable.""" + executable = name + (".exe" if os.name == "nt" else "") + local = Path(sys.executable).with_name(executable) + return shutil.which(executable) or (str(local) if local.is_file() else None) + + +CMAKE = find_build_tool("cmake") +NINJA = find_build_tool("ninja") +BUILD_GENERATORS = [None] if os.name == "nt" else ["Unix Makefiles"] +if NINJA and (os.name != "nt" or os.environ.get("VCINSTALLDIR")): + BUILD_GENERATORS.append("Ninja") +if sys.platform == "darwin": + BUILD_GENERATORS.append("Xcode") + + +@pytest.mark.ciSkip +@pytest.mark.buildIntegration +@pytest.mark.skipif(CMAKE is None, reason="CMake is required") +@pytest.mark.parametrize("generator", BUILD_GENERATORS) +def test_package_configuration_checks_native_targets(tmp_path, monkeypatch, generator): + """Accept header-only packages and detect accidental binary targets. + + :param tmp_path: Temporary directory supplied by pytest. + :param monkeypatch: Pytest helper for selecting the CMake executable. + :param generator: Native CMake generator to exercise. + """ + if generator == "Xcode": + xcodebuild = shutil.which("xcodebuild") + if xcodebuild is None: + pytest.skip("Xcode is not installed") + version = subprocess.run( + [xcodebuild, "-version"], capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, + ) + if version.returncode: + pytest.skip("A full Xcode installation must be selected to test its generator") + + project = tmp_path / "source" + build = tmp_path / "build" + (project / "cmake").mkdir(parents=True) + for helper in ("bskCollectWrapperCustomFiles.cmake", "bskSourceInventory.cmake"): + shutil.copyfile(REPOSITORY_ROOT / "src/cmake" / helper, project / "cmake" / helper) + (project / "fixture.c").write_text("int fixture(void) { return 0; }\n", encoding="utf-8") + declarations = [ + "cmake_minimum_required(VERSION 3.26)", + "project(packageCheckFixture LANGUAGES C)", + # A similar name must not be mistaken for a header-only package. + "add_library(communicationLibHelper STATIC fixture.c)", + ] + declarations.append('''foreach(package communicationLib vizardLib) + if(package STREQUAL BINARY_PACKAGE) + add_library(${package} STATIC fixture.c) + else() + add_library(${package} INTERFACE) + endif() +endforeach()''') + (project / "CMakeLists.txt").write_text("\n".join(declarations) + "\n", encoding="utf-8") + command = [CMAKE, "-S", str(project), "-B", str(build)] + if generator: + command.extend(["-G", generator]) + if generator == "Ninja": + command.append(f"-DCMAKE_MAKE_PROGRAM={NINJA}") + check = CHECKS["assert_package_configuration"] + monkeypatch.setitem(check.__globals__, "cmake_executable", lambda: CMAKE) + # Reuse compiler detection while exercising each actual File API target set. + for binary_package in ("", "communicationLib", "vizardLib", ""): + result = subprocess.run( + command + [f"-DBINARY_PACKAGE={binary_package}"], capture_output=True, text=True, + timeout=SUBPROCESS_TIMEOUT_SECONDS, + ) + assert result.returncode == 0, result.stdout + result.stderr + if binary_package: + with pytest.raises(CHECKS["IncrementalBuildError"], match=f"binary library targets: {binary_package}"): + check(project, build) + else: + check(project, build) diff --git a/src/tests/test_optionalWheelBuilder.py b/src/tests/test_optionalWheelBuilder.py index dbbf70ebdd8..538de98fe26 100644 --- a/src/tests/test_optionalWheelBuilder.py +++ b/src/tests/test_optionalWheelBuilder.py @@ -24,6 +24,7 @@ import venv import zipfile from contextlib import contextmanager +from functools import cache from importlib.metadata import distributions from pathlib import Path from types import ModuleType @@ -64,6 +65,34 @@ def _loadWheelTester(): WHEEL_TESTER = _loadWheelTester() +@pytest.mark.parametrize("suffix", ["so", "pyd"]) +def test_grouped_opnav_wheel_delta(suffix, monkeypatch): + """Package grouped OpenCV bindings without accepting incomplete or mixed layouts. + + :param suffix: Native extension suffix for Unix or Windows wheels. + :param monkeypatch: Pytest helper for reusing the unchanged source inventory. + """ + # Discover the real modules once; each missing-file case uses the same tree. + monkeypatch.setattr( + WHEEL_BUILDER, "discover_cmake_include_modules", + cache(WHEEL_BUILDER.discover_cmake_include_modules), + ) + component = WHEEL_BUILDER.COMPONENTS["opnav"] + base = {"Basilisk/__init__.py", "Basilisk/simulation/_simulationCoreNative." + suffix} + delta = {"Basilisk/_opNavNative." + suffix, "Basilisk/_load_opnav.py"} + for package, name in (("simulation", "camera"), ("fswAlgorithms", "centerRadiusCNN"), + ("fswAlgorithms", "houghCircles"), ("fswAlgorithms", "limbFinding")): + delta.update({f"Basilisk/{package}/{name}.py", f"Basilisk/{package}/_{name}.py"}) + assert WHEEL_BUILDER.validate_delta(base, base | delta, component) == sorted(delta) + for required in delta: + with pytest.raises(ValueError): + WHEEL_BUILDER.validate_delta(base, base | (delta - {required}), component) + with pytest.raises(ValueError, match="unexpected"): + WHEEL_BUILDER.validate_delta(base, base | delta | {"Basilisk/simulation/_camera." + suffix}, component) + with pytest.raises(ValueError, match="Base wheel"): + WHEEL_BUILDER.validate_delta(base | {"Basilisk/_load_opnav.py"}, base | delta, component) + + def _buildInfoData(opNavEnabled, *, schemaVersion=4, diagnostics=None): buildInfo = { "schemaVersion": schemaVersion,