From 23741effd3902820cc96a7cdffab0788c8522ed3 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Thu, 9 Oct 2025 17:14:33 +0100 Subject: [PATCH 01/20] Fix: CasADi linking to work with pip isolated build environments This commit resolves a build failure that occurred when building the package with pip/nox due to pip's use of isolated and changing build environments. Problem: - CMake was using find_library() to get the absolute path to libcasadi.dylib during configuration in one temporary pip build environment - By the time the actual build ran, pip had switched to a different temporary build environment, making the hard-coded library path invalid - This caused: "No rule to make target .../libcasadi.dylib" error Solution: - Link against CasADi by library name instead of absolute path, relying on target_link_directories() to provide the search path during build - Use relative RPATH (@loader_path on macOS, $ORIGIN on Linux) so the built module can find CasADi in the same Python environment at runtime - Set BUILD_RPATH for the build environment and INSTALL_RPATH for runtime - Remove duplicate casadi link (already added in USE_PYTHON_CASADI section) --- CMakeLists.txt | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3afce41..702c194 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,19 +137,31 @@ if (${USE_PYTHON_CASADI}) message("Found Python CasADi library directory: ${CASADI_LIB_DIR}") target_link_directories(idaklu PRIVATE ${CASADI_LIB_DIR}) - set_target_properties( - idaklu PROPERTIES - INSTALL_RPATH "${CASADI_LIB_DIR}" - INSTALL_RPATH_USE_LINK_PATH TRUE - ) - # Attempt to link the casadi library directly if found - find_library(CASADI_LIBRARY NAMES casadi PATHS ${CASADI_LIB_DIR} NO_DEFAULT_PATH) - if (CASADI_LIBRARY) - message("Found CasADi library: ${CASADI_LIBRARY}") - target_link_libraries(idaklu PRIVATE ${CASADI_LIBRARY}) - else () - message(WARNING "CasADi library not found in ${CASADI_LIB_DIR}. The target will rely on transitive linkage via CMake config if available.") - endif () + # Set RPATH to find libraries relative to the module location + # This allows finding casadi in the same Python environment at runtime + # Module is at: site-packages/pybammsolvers/idaklu.so + # CasADi is at: site-packages/casadi/libcasadi.dylib + # Note: Windows uses vcpkg with static linking, no RPATH needed + if (APPLE) + set_target_properties( + idaklu PROPERTIES + BUILD_RPATH "${CASADI_LIB_DIR}" + BUILD_RPATH_USE_LINK_PATH FALSE + INSTALL_RPATH "@loader_path/../casadi" + BUILD_WITH_INSTALL_RPATH FALSE + ) + else() + set_target_properties( + idaklu PROPERTIES + BUILD_RPATH "${CASADI_LIB_DIR}" + BUILD_RPATH_USE_LINK_PATH FALSE + INSTALL_RPATH "$ORIGIN/../casadi" + BUILD_WITH_INSTALL_RPATH FALSE + ) + endif() + # Link against casadi by name, not absolute path, to avoid issues with + # pip's isolated build environments changing paths between configure and build + target_link_libraries(idaklu PRIVATE casadi) else () message(FATAL_ERROR "Could not find CasADi library directory") endif () @@ -180,7 +192,7 @@ set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${PROJECT_SOURCE_DIR}) find_package(SUNDIALS REQUIRED) message("SUNDIALS found in ${SUNDIALS_INCLUDE_DIR}: ${SUNDIALS_LIBRARIES}") target_include_directories(idaklu PRIVATE ${SUNDIALS_INCLUDE_DIR}) -target_link_libraries(idaklu PRIVATE ${SUNDIALS_LIBRARIES} casadi) +target_link_libraries(idaklu PRIVATE ${SUNDIALS_LIBRARIES}) # link suitesparse # if using vcpkg, use config mode to From 7551a6d56849339b1fb5ffa7a63030a41a1b987d Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Thu, 9 Oct 2025 17:15:36 +0100 Subject: [PATCH 02/20] tests: adds initial test suite w/ basic functionality --- noxfile.py | 55 ++++++- pyproject.toml | 4 +- pytest.ini | 22 +++ tests/README.md | 49 ++++++ tests/conftest.py | 46 ++++++ tests/test_functions.py | 113 ++++++++++++++ tests/test_imports.py | 5 - tests/test_integration.py | 176 ++++++++++++++++++++++ tests/test_module.py | 171 +++++++++++++++++++++ tests/test_performance.py | 116 +++++++++++++++ tests/test_vectors.py | 305 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 1055 insertions(+), 7 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/test_functions.py delete mode 100644 tests/test_imports.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_module.py create mode 100644 tests/test_performance.py create mode 100644 tests/test_vectors.py diff --git a/noxfile.py b/noxfile.py index 128843b..c8d1d9a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -51,8 +51,61 @@ def run_pybamm_requires(session): @nox.session(name="unit") def run_unit(session): + """Run the full test suite.""" set_environment_variables(PYBAMM_ENV, session=session) session.install("setuptools", silent=False) session.install("casadi==3.6.7", silent=False) session.install(".[dev]", silent=False) - session.run("pytest", "tests") + session.run("pytest", "tests", *session.posargs) + + +@nox.session(name="unit-fast") +def run_unit_fast(session): + """Run fast tests only (excluding slow and integration tests).""" + set_environment_variables(PYBAMM_ENV, session=session) + session.install("setuptools", silent=False) + session.install("casadi==3.6.7", silent=False) + session.install(".[dev]", silent=False) + session.run( + "pytest", "tests", "-m", "not slow and not integration", *session.posargs + ) + + +@nox.session(name="unit-integration") +def run_unit_integration(session): + """Run integration tests only.""" + set_environment_variables(PYBAMM_ENV, session=session) + session.install("setuptools", silent=False) + session.install("casadi==3.6.7", silent=False) + session.install(".[dev]", silent=False) + session.run("pytest", "tests", "-m", "integration", "-v", *session.posargs) + + +@nox.session(name="unit-performance") +def run_unit_performance(session): + """Run performance tests.""" + set_environment_variables(PYBAMM_ENV, session=session) + session.install("setuptools", silent=False) + session.install("casadi==3.6.7", silent=False) + session.install(".[dev]", silent=False) + # Install optional performance testing dependencies + session.install("psutil", silent=False) + session.run("pytest", "tests/test_performance.py", "-v", *session.posargs) + + +@nox.session(name="coverage") +def run_coverage(session): + """Run tests with coverage reporting.""" + set_environment_variables(PYBAMM_ENV, session=session) + session.install("setuptools", silent=False) + session.install("casadi==3.6.7", silent=False) + session.install(".[dev]", silent=False) + session.install("pytest-cov", silent=False) + session.run( + "pytest", + "tests", + "--cov=pybammsolvers", + "--cov-report=html", + "--cov-report=term-missing", + *session.posargs, + ) diff --git a/pyproject.toml b/pyproject.toml index 790e517..2fa7408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,12 +19,14 @@ license-files = ["LICENSE"] dynamic = ["version", "readme"] dependencies = [ "casadi==3.6.7", - "numpy" + "numpy", ] [project.optional-dependencies] dev = [ "pytest", + "pytest-cov", + "psutil>=7.1.0", "setuptools", "wheel", ] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4fb714f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,22 @@ +[tool:pytest] +# Pytest configuration for pybammsolvers +minversion = 6.0 +addopts = + -ra + --strict-markers + --strict-config + --tb=short + -v +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests +filterwarnings = + ignore::UserWarning + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..5f32998 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,49 @@ +# PyBaMM Solvers Test Suite + +This directory contains the standardized test suite for the pybammsolvers package. + +## Running Tests + +### Using Nox (Recommended) + +```bash +# Run tests through nox (handles dependencies) +nox -s test + +# Run with specific Python version +nox -s test --python 3.11 +``` + +### Using Pytest + +```bash +# Run all tests +pytest + +# Run with verbose output +pytest -v + +# Run specific test file +pytest tests/test_module.py + +# Run specific test class +pytest tests/test_vectors.py::TestVectorNdArrayBasic + +# Run specific test +pytest tests/test_module.py::TestImport::test_pybammsolvers_import +``` + +### Using Test Markers + +Tests are categorized with markers for selective execution: + +```bash +# Run only fast tests (exclude slow tests) +pytest -m "not slow" + +# Run only integration tests +pytest -m integration + +# Combine markers +pytest -m "not slow and not integration" +``` \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7d06556 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +"""Pytest configuration and fixtures for pybammsolvers tests.""" + +import pytest +import os + + +def pytest_configure(config): + """Configure pytest with custom markers.""" + config.addinivalue_line( + "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" + ) + config.addinivalue_line("markers", "integration: marks tests as integration tests") + + +@pytest.fixture(scope="session") +def idaklu_module(): + """Fixture to provide the idaklu module.""" + try: + import pybammsolvers + return pybammsolvers.idaklu + except ImportError as e: + pytest.skip(f"Could not import pybammsolvers.idaklu: {e}") + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to add markers based on test names.""" + for item in items: + # Add slow marker to performance tests + if "performance" in item.name.lower() or "TestVectorPerformance" in str(item.parent): + item.add_marker(pytest.mark.slow) + + # Add integration marker + if "integration" in item.name.lower() or "test_integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) + + +@pytest.fixture(autouse=True) +def setup_test_environment(): + """Set up test environment variables.""" + # Ensure consistent backend for any plotting + os.environ["MPLBACKEND"] = "Agg" + + # Set encoding for consistent behavior + os.environ["PYTHONIOENCODING"] = "utf-8" + + yield diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..f61d067 --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,113 @@ +"""Test module-level functions. + +This module tests the standalone functions provided by the idaklu module, +including observe, generate_function, and other utility functions. +""" + +import pytest +import numpy as np + + +class TestObserveFunction: + """Test the observe function.""" + + def test_observe_exists(self, idaklu_module): + """Test that observe function exists.""" + assert hasattr(idaklu_module, "observe") + assert callable(idaklu_module.observe) + + def test_observe_with_empty_arrays(self, idaklu_module): + """Test observe with empty arrays raises TypeError.""" + with pytest.raises(TypeError): + idaklu_module.observe( + ts=np.array([]), + ys=np.array([]), + inputs=np.array([]), + funcs=[], + is_f_contiguous=True, + shape=[], + ) + + +class TestObserveHermiteInterpFunction: + """Test the observe_hermite_interp function.""" + + def test_observe_hermite_interp_exists(self, idaklu_module): + """Test that observe_hermite_interp function exists.""" + assert hasattr(idaklu_module, "observe_hermite_interp") + assert callable(idaklu_module.observe_hermite_interp) + + def test_observe_hermite_interp_with_empty_arrays(self, idaklu_module): + """Test observe_hermite_interp with empty arrays raises TypeError.""" + with pytest.raises(TypeError): + idaklu_module.observe_hermite_interp( + t_interp=np.array([]), + ts=np.array([]), + ys=np.array([]), + yps=np.array([]), + inputs=np.array([]), + funcs=[], + shape=[], + ) + + +class TestGenerateFunction: + """Test the generate_function.""" + + def test_generate_function_exists(self, idaklu_module): + """Test that generate_function exists.""" + assert hasattr(idaklu_module, "generate_function") + assert callable(idaklu_module.generate_function) + + def test_generate_function_with_empty_string(self, idaklu_module): + """Test generate_function with empty string raises RuntimeError.""" + with pytest.raises(RuntimeError): + idaklu_module.generate_function("") + + def test_generate_function_with_invalid_input(self, idaklu_module): + """Test generate_function with invalid CasADi expression.""" + with pytest.raises(RuntimeError): + idaklu_module.generate_function("invalid_casadi_expression") + + +class TestCreateCasadiSolverGroup: + """Test the create_casadi_solver_group function.""" + + def test_create_casadi_solver_group_exists(self, idaklu_module): + """Test that create_casadi_solver_group function exists.""" + assert hasattr(idaklu_module, "create_casadi_solver_group") + assert callable(idaklu_module.create_casadi_solver_group) + + def test_create_casadi_solver_group_without_parameters(self, idaklu_module): + """Test that function fails appropriately without parameters.""" + with pytest.raises(TypeError): + idaklu_module.create_casadi_solver_group() + + +class TestCreateIdakluJax: + """Test the create_idaklu_jax function.""" + + def test_create_idaklu_jax_exists(self, idaklu_module): + """Test that create_idaklu_jax function exists.""" + assert hasattr(idaklu_module, "create_idaklu_jax") + assert callable(idaklu_module.create_idaklu_jax) + + def test_create_idaklu_jax_callable(self, idaklu_module): + """Test that create_idaklu_jax can be called.""" + result = idaklu_module.create_idaklu_jax() + assert result is not None + + +class TestRegistrationsFunction: + """Test the registrations function.""" + + def test_registrations_exists(self, idaklu_module): + """Test that registrations function exists.""" + assert hasattr(idaklu_module, "registrations") + assert callable(idaklu_module.registrations) + + def test_registrations_callable(self, idaklu_module): + """Test that registrations function can be called.""" + result = idaklu_module.registrations() + assert result is not None + diff --git a/tests/test_imports.py b/tests/test_imports.py deleted file mode 100644 index 1b34d08..0000000 --- a/tests/test_imports.py +++ /dev/null @@ -1,5 +0,0 @@ -import pybammsolvers - - -def test_import(): - assert hasattr(pybammsolvers, "idaklu") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..69116b8 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,176 @@ +"""Integration tests for pybammsolvers. + +These tests verify interactions between different components +and may require more complex setup or be slower to run. +""" + +import pytest +import numpy as np +import gc + + +class TestVectorIntegration: + """Test integration between different vector types and operations.""" + + @pytest.mark.integration + def test_mixed_vector_operations(self, idaklu_module): + """Test mixed operations with different array types.""" + nd_vector = idaklu_module.VectorNdArray() + + arrays = [ + np.array([1.0, 2.0, 3.0]), + np.array([[1, 2], [3, 4]]), + np.ones((5,)), + np.zeros((2, 3)), + ] + + for arr in arrays: + nd_vector.append(arr.astype(np.float64)) + + assert len(nd_vector) == len(arrays) + + # Verify retrieval maintains shape and values + for i, original in enumerate(arrays): + retrieved = nd_vector[i] + np.testing.assert_array_equal(retrieved, original.astype(np.float64)) + + +class TestErrorRecovery: + """Test error handling and recovery in integration scenarios.""" + + @pytest.mark.integration + def test_partial_failure_recovery(self, idaklu_module): + """Test recovery from partial failures.""" + vector = idaklu_module.VectorNdArray() + + # Add valid arrays + valid_arrays = [np.array([1.0, 2.0]), np.array([3.0, 4.0])] + for arr in valid_arrays: + vector.append(arr) + + assert len(vector) == 2 + + # Try to add invalid data + try: + vector.append("invalid") + except (TypeError, ValueError): + pass # Expected to fail + + # Verify valid data is still accessible + assert len(vector) == 2 + np.testing.assert_array_equal(vector[0], valid_arrays[0]) + np.testing.assert_array_equal(vector[1], valid_arrays[1]) + + # Should be able to continue adding valid data + vector.append(np.array([5.0, 6.0])) + assert len(vector) == 3 + + @pytest.mark.integration + @pytest.mark.slow + def test_large_data_handling(self, idaklu_module): + """Test handling of moderately large datasets.""" + vector = idaklu_module.VectorNdArray() + + large_arrays = [] + for i in range(10): + arr = np.random.rand(100, 50).astype(np.float64) + large_arrays.append(arr) + vector.append(arr) + + assert len(vector) == 10 + + # Verify all arrays are accessible and correct + for i, original in enumerate(large_arrays): + retrieved = vector[i] + assert retrieved.shape == original.shape + np.testing.assert_array_equal(retrieved, original) + + +class TestMemoryManagement: + """Test memory management in integration scenarios.""" + + @pytest.mark.integration + def test_memory_cleanup_basic(self, idaklu_module): + """Test that memory is properly cleaned up.""" + vectors = [] + + # Create many vector objects + for _ in range(100): + vector = idaklu_module.VectorNdArray() + for _ in range(10): + arr = np.random.rand(100).astype(np.float64) + vector.append(arr) + vectors.append(vector) + + # Clear references + vectors.clear() + gc.collect() + + # If we get here without crashing, memory management is working + + @pytest.mark.integration + def test_solution_vector_memory_cleanup(self, idaklu_module): + """Test memory cleanup for solution vectors.""" + vectors = [] + + # Create many solution vector objects + for _ in range(100): + vector = idaklu_module.VectorSolution() + vectors.append(vector) + + # Clear references + vectors.clear() + gc.collect() + + # If we get here without crashing, memory management is working + + +@pytest.mark.integration +@pytest.mark.slow +class TestStressConditions: + """Test behavior under stress conditions.""" + + def test_concurrent_access_simulation(self, idaklu_module): + """Simulate concurrent access patterns (single-threaded).""" + vector = idaklu_module.VectorNdArray() + + # Add initial data + for i in range(100): + arr = np.array([i, i + 1, i + 2], dtype=np.float64) + vector.append(arr) + + # Simulate mixed read/write operations + for _ in range(1000): + # Random read + idx = np.random.randint(0, len(vector)) + _ = vector[idx] + + # Occasional write + if np.random.rand() < 0.1: # 10% chance + new_arr = np.random.rand(3).astype(np.float64) + vector.append(new_arr) + + # Verify integrity + assert len(vector) >= 100 + + def test_boundary_stress(self, idaklu_module): + """Test boundary conditions under stress.""" + vector = idaklu_module.VectorNdArray() + + # Mix of extreme values + extreme_arrays = [ + np.array([1e-100], dtype=np.float64), + np.array([1e100], dtype=np.float64), + np.array([np.inf], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([np.finfo(np.float64).tiny], dtype=np.float64), + ] + + for arr in extreme_arrays: + vector.append(arr) + + # Repeatedly access these + for _ in range(100): + for i in range(len(vector)): + retrieved = vector[i] + assert retrieved is not None diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..7331d03 --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,171 @@ +"""Test module imports and basic structure. + +This module consolidates all tests related to module imports, +class/function availability, and basic documentation. +""" + +import pytest +import inspect +import io +import contextlib + + +class TestImport: + """Test basic module import functionality.""" + + def test_pybammsolvers_import(self): + """Test that pybammsolvers can be imported.""" + import pybammsolvers + assert pybammsolvers is not None + + def test_idaklu_module_import(self, idaklu_module): + """Test that idaklu module is accessible.""" + assert idaklu_module is not None + + def test_version_import(self): + """Test that version can be imported.""" + from pybammsolvers.version import __version__ + assert __version__ is not None + assert isinstance(__version__, str) + + +class TestClasses: + """Test that all expected classes are available.""" + + def test_solver_group_class_exists(self, idaklu_module): + """Test that IDAKLUSolverGroup class exists.""" + assert hasattr(idaklu_module, "IDAKLUSolverGroup") + assert callable(idaklu_module.IDAKLUSolverGroup) + + def test_idaklu_jax_class_exists(self, idaklu_module): + """Test that IdakluJax class exists.""" + assert hasattr(idaklu_module, "IdakluJax") + assert callable(idaklu_module.IdakluJax) + + def test_solution_class_exists(self, idaklu_module): + """Test that solution class exists.""" + assert hasattr(idaklu_module, "solution") + assert callable(idaklu_module.solution) + + def test_vector_classes_exist(self, idaklu_module): + """Test that vector classes exist.""" + assert hasattr(idaklu_module, "VectorNdArray") + assert hasattr(idaklu_module, "VectorRealtypeNdArray") + assert hasattr(idaklu_module, "VectorSolution") + + def test_function_class_exists(self, idaklu_module): + """Test that Function class exists (CasADi).""" + assert hasattr(idaklu_module, "Function") + + +class TestFunctions: + """Test that all expected functions are available.""" + + @pytest.mark.parametrize( + "func_name", + [ + "create_casadi_solver_group", + "observe", + "observe_hermite_interp", + "generate_function", + "create_idaklu_jax", + "registrations", + ], + ) + def test_function_exists_and_callable(self, idaklu_module, func_name): + """Test that critical functions exist and are callable.""" + assert hasattr(idaklu_module, func_name), f"Missing function: {func_name}" + func = getattr(idaklu_module, func_name) + assert callable(func), f"Function {func_name} is not callable" + + +class TestDocumentation: + """Test module and class documentation.""" + + def test_module_has_docstring(self, idaklu_module): + """Test that the idaklu module has documentation.""" + assert hasattr(idaklu_module, "__doc__") + assert idaklu_module.__doc__ is not None + assert len(idaklu_module.__doc__.strip()) > 0 + + @pytest.mark.parametrize( + "class_name", + ["IDAKLUSolverGroup", "IdakluJax", "solution"], + ) + def test_class_has_docstring(self, idaklu_module, class_name): + """Test that main classes have docstrings.""" + cls = getattr(idaklu_module, class_name) + assert hasattr(cls, "__doc__") + + def test_help_functionality(self, idaklu_module): + """Test that help() works on main components.""" + components = [ + idaklu_module, + idaklu_module.solution, + idaklu_module.VectorNdArray, + ] + + for component in components: + f = io.StringIO() + with contextlib.redirect_stdout(f): + help(component) + help_text = f.getvalue() + assert len(help_text) > 0 + + @pytest.mark.parametrize( + "func_name", + [ + "create_casadi_solver_group", + "observe", + "observe_hermite_interp", + "generate_function", + ], + ) + def test_function_has_signature(self, idaklu_module, func_name): + """Test that functions have reasonable signatures.""" + func = getattr(idaklu_module, func_name) + + # Should be callable + assert callable(func) + + # Try to get signature (might fail for C++ functions) + try: + sig = inspect.signature(func) + assert sig is not None + except (ValueError, TypeError): + # C++ functions might not have inspectable signatures + pytest.skip(f"{func_name} signature not inspectable (C++ binding)") + + +class TestBasicFunctionality: + """Test basic functionality that doesn't require complex setup.""" + + def test_registrations_function(self, idaklu_module): + """Test that registrations function can be called.""" + result = idaklu_module.registrations() + assert result is not None + + def test_create_idaklu_jax_function(self, idaklu_module): + """Test that create_idaklu_jax function can be called.""" + result = idaklu_module.create_idaklu_jax() + assert result is not None + + def test_solver_group_creation_without_parameters(self, idaklu_module): + """Test that solver group creation fails appropriately without parameters.""" + with pytest.raises(TypeError): + idaklu_module.create_casadi_solver_group() + + +class TestErrorHandling: + """Test error handling for invalid inputs.""" + + def test_generate_function_with_empty_string(self, idaklu_module): + """Test generate_function with empty string.""" + with pytest.raises(RuntimeError): + idaklu_module.generate_function("") + + def test_generate_function_with_invalid_expression(self, idaklu_module): + """Test generate_function with invalid CasADi expression.""" + with pytest.raises(RuntimeError): + idaklu_module.generate_function("invalid_casadi_expression") + diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..6643da5 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,116 @@ +"""Performance and memory tests for pybammsolvers. + +These tests check performance characteristics and memory usage. +All tests in this module are marked as slow. +""" + +import pytest +import numpy as np +import time +import gc + + +@pytest.mark.slow +class TestMemoryUsage: + """Test memory usage and potential leaks.""" + + def test_memory_cleanup_with_psutil(self, idaklu_module): + """Test that memory is properly cleaned up using psutil.""" + try: + import psutil + import os + except ImportError: + pytest.skip("psutil not available for memory testing") + + # Get initial memory usage + process = psutil.Process(os.getpid()) + initial_memory = process.memory_info().rss + + # Create and destroy many objects + for _ in range(100): + vector = idaklu_module.VectorNdArray() + for _ in range(10): + arr = np.random.rand(100).astype(np.float64) + vector.append(arr) + del vector + + # Force garbage collection + gc.collect() + + # Check memory usage after cleanup + final_memory = process.memory_info().rss + memory_increase = final_memory - initial_memory + + # Allow for some memory increase but not excessive + max_allowed_increase = 100 * 1024 # 100 KB + + assert memory_increase < max_allowed_increase, ( + f"Memory increased by {memory_increase / 1024:.1f} KB, " + f"which exceeds limit of {max_allowed_increase / 1024:.1f} KB" + ) + + +@pytest.mark.slow +class TestPerformanceBenchmarks: + """Benchmark performance of key operations.""" + + def test_append_performance_scaling(self, idaklu_module): + """Test that append performance scales reasonably.""" + vector = idaklu_module.VectorNdArray() + + # Test append time doesn't degrade significantly + times = [] + for batch in range(5): + start_time = time.time() + for _ in range(100): + arr = np.random.rand(50).astype(np.float64) + vector.append(arr) + elapsed = time.time() - start_time + times.append(elapsed) + + # Later batches shouldn't be much slower than early ones + # Allow for some variation but check it's not exponential + assert times[-1] < times[0] * 1.2, "Append performance degraded significantly" + + def test_access_performance_scaling(self, idaklu_module): + """Test that access performance doesn't degrade with size.""" + vector = idaklu_module.VectorNdArray() + + # Build up a reasonably large vector + for i in range(1000): + vector.append(np.array([i], dtype=np.float64)) + + # Test access at different points + access_times = [] + for idx in [0, 250, 500, 750, 999]: + start_time = time.time() + for _ in range(100): + _ = vector[idx] + elapsed = time.time() - start_time + access_times.append(elapsed) + + # Access time should be relatively constant (not index-dependent) + avg_time = np.mean(access_times) + for t in access_times: + assert t < avg_time * 1.5, f"Access time {t} significantly differs from average {avg_time}" + + def test_large_array_copy_performance(self, idaklu_module): + """Test performance of copying large arrays.""" + vector = idaklu_module.VectorNdArray() + + # Create a large array + large_array = np.random.rand(10000, 100).astype(np.float64) + + start_time = time.time() + vector.append(large_array) + append_time = time.time() - start_time + + # Should be fast (< 1 second for 1M elements) + assert append_time < 1.0, f"Large array append took {append_time:.3f}s" + + start_time = time.time() + retrieved = vector[0] + retrieval_time = time.time() - start_time + + assert retrieval_time < 1.0, f"Large array retrieval took {retrieval_time:.3f}s" + assert retrieved.shape == large_array.shape diff --git a/tests/test_vectors.py b/tests/test_vectors.py new file mode 100644 index 0000000..7631a2a --- /dev/null +++ b/tests/test_vectors.py @@ -0,0 +1,305 @@ +"""Test vector container classes. + +This module consolidates all tests related to VectorNdArray, +VectorRealtypeNdArray, and VectorSolution classes. +""" + +import pytest +import numpy as np + + +class TestVectorNdArrayBasic: + """Test basic VectorNdArray functionality.""" + + def test_creation(self, idaklu_module): + """Test VectorNdArray can be created.""" + vector = idaklu_module.VectorNdArray() + assert vector is not None + assert len(vector) == 0 + + def test_append_and_access(self, idaklu_module): + """Test appending arrays and accessing them.""" + vector = idaklu_module.VectorNdArray() + + arr1 = np.array([1.0, 2.0, 3.0]) + arr2 = np.array([4.0, 5.0, 6.0]) + + vector.append(arr1) + assert len(vector) == 1 + + vector.append(arr2) + assert len(vector) == 2 + + # Test access + retrieved = vector[0] + np.testing.assert_array_equal(retrieved, arr1) + + retrieved = vector[1] + np.testing.assert_array_equal(retrieved, arr2) + + def test_empty_vector_access_raises_error(self, idaklu_module): + """Test that accessing empty vector raises IndexError.""" + vector = idaklu_module.VectorNdArray() + with pytest.raises(IndexError): + _ = vector[0] + + def test_multiple_arrays_different_shapes(self, idaklu_module): + """Test vector can hold arrays of different shapes.""" + vector = idaklu_module.VectorNdArray() + + arrays = [ + np.array([1.0, 2.0, 3.0]), + np.array([[1, 2], [3, 4]]), + np.ones((5,)), + np.zeros((2, 3)), + ] + + for arr in arrays: + vector.append(arr.astype(np.float64)) + + assert len(vector) == len(arrays) + + # Verify retrieval + for i, original in enumerate(arrays): + retrieved = vector[i] + np.testing.assert_array_equal(retrieved, original.astype(np.float64)) + + +class TestVectorNdArrayEdgeCases: + """Test edge cases for VectorNdArray.""" + + def test_zero_dimensional_arrays(self, idaklu_module): + """Test handling of 0-dimensional arrays.""" + vector = idaklu_module.VectorNdArray() + + scalar_array = np.array(42.0) + assert scalar_array.ndim == 0 + + vector.append(scalar_array) + assert len(vector) == 1 + + retrieved = vector[0] + assert retrieved.shape == () + assert float(retrieved) == 42.0 + + def test_large_dimensions(self, idaklu_module): + """Test arrays with many dimensions but small total size.""" + vector = idaklu_module.VectorNdArray() + + shape = (1, 1, 1, 1, 1, 1, 1, 1, 2) + arr = np.ones(shape, dtype=np.float64) + + vector.append(arr) + assert len(vector) == 1 + + retrieved = vector[0] + assert retrieved.shape == shape + np.testing.assert_array_equal(retrieved, arr) + + def test_special_float_values(self, idaklu_module): + """Test handling of inf and nan.""" + vector = idaklu_module.VectorNdArray() + + # Test with infinity + inf_array = np.array([np.inf, -np.inf, 1.0]) + vector.append(inf_array) + retrieved = vector[0] + assert np.isinf(retrieved[0]) + assert np.isinf(retrieved[1]) + assert retrieved[2] == 1.0 + + # Test with NaN + nan_array = np.array([np.nan, 1.0, 2.0]) + vector.append(nan_array) + retrieved = vector[-1] + assert np.isnan(retrieved[0]) + assert retrieved[1] == 1.0 + + def test_extremely_small_values(self, idaklu_module): + """Test handling of extremely small values.""" + vector = idaklu_module.VectorNdArray() + + tiny_values = np.array([ + np.finfo(np.float64).tiny, + np.finfo(np.float64).eps, + 1e-300, + 0.0, + ]) + + vector.append(tiny_values) + retrieved = vector[0] + np.testing.assert_array_equal(retrieved, tiny_values) + + def test_extremely_large_values(self, idaklu_module): + """Test handling of extremely large values.""" + vector = idaklu_module.VectorNdArray() + + large_values = np.array([ + 1e100, + 1e200, + np.finfo(np.float64).max / 2, + -1e100, + ]) + + vector.append(large_values) + retrieved = vector[0] + np.testing.assert_array_equal(retrieved, large_values) + + def test_negative_indexing(self, idaklu_module): + """Test negative indexing behavior.""" + vector = idaklu_module.VectorNdArray() + + arrays = [np.array([i], dtype=np.float64) for i in range(5)] + for arr in arrays: + vector.append(arr) + + try: + last_elem = vector[-1] + assert last_elem[0] == 4.0 + + second_last = vector[-2] + assert second_last[0] == 3.0 + except (IndexError, TypeError): + pytest.skip("Negative indexing not supported") + + @pytest.mark.slow + def test_maximum_vector_size(self, idaklu_module): + """Test vector behavior near maximum reasonable size.""" + vector = idaklu_module.VectorNdArray() + + max_size = 1000 + for i in range(max_size): + arr = np.array([i], dtype=np.float64) + vector.append(arr) + + if i % 100 == 0 and i > 0: + first_elem = vector[0] + assert first_elem[0] == 0.0 + + assert len(vector) == max_size + + # Test access at key positions + for i in [0, max_size // 2, max_size - 1]: + elem = vector[i] + assert elem[0] == float(i) + + +class TestVectorNdArrayTypeHandling: + """Test type coercion and conversion behavior.""" + + def test_integer_array_coercion(self, idaklu_module): + """Test coercion of integer arrays.""" + vector = idaklu_module.VectorNdArray() + + int_array = np.array([1, 2, 3], dtype=np.int32) + vector.append(int_array) + retrieved = vector[0] + + assert retrieved.dtype in [np.float64, np.float32, np.int32, np.int64] + np.testing.assert_array_equal(retrieved.astype(int), [1, 2, 3]) + + def test_complex_array_handling(self, idaklu_module): + """Test handling of complex arrays.""" + vector = idaklu_module.VectorNdArray() + + complex_array = np.array([1 + 2j, 3 + 4j], dtype=np.complex128) + vector.append(complex_array) + retrieved = vector[0] + assert retrieved is not None + + def test_boolean_array_handling(self, idaklu_module): + """Test handling of boolean arrays.""" + vector = idaklu_module.VectorNdArray() + + bool_array = np.array([True, False, True], dtype=bool) + vector.append(bool_array) + retrieved = vector[0] + assert retrieved.dtype in [bool, np.int32, np.int64, np.float32, np.float64] + + +class TestVectorRealtypeNdArray: + """Test VectorRealtypeNdArray functionality.""" + + def test_creation(self, idaklu_module): + """Test VectorRealtypeNdArray can be created.""" + vector = idaklu_module.VectorRealtypeNdArray() + assert vector is not None + assert len(vector) == 0 + + +class TestVectorSolution: + """Test VectorSolution functionality.""" + + def test_creation(self, idaklu_module): + """Test VectorSolution can be created.""" + vector = idaklu_module.VectorSolution() + assert vector is not None + assert len(vector) == 0 + assert hasattr(vector, "__len__") + + def test_has_append_method(self, idaklu_module): + """Test VectorSolution has append method.""" + vector = idaklu_module.VectorSolution() + assert hasattr(vector, "append") + + +@pytest.mark.slow +class TestVectorPerformance: + """Test performance characteristics of vectors.""" + + def test_large_array_append_retrieval(self, idaklu_module): + """Test performance with large arrays.""" + import time + + vector = idaklu_module.VectorNdArray() + large_array = np.random.rand(1000, 100).astype(np.float64) + + start_time = time.time() + vector.append(large_array) + append_time = time.time() - start_time + assert append_time < 1.0 + + start_time = time.time() + retrieved = vector[0] + retrieval_time = time.time() - start_time + assert retrieval_time < 1.0 + assert retrieved.shape == large_array.shape + + def test_many_small_arrays(self, idaklu_module): + """Test performance with many small arrays.""" + import time + + vector = idaklu_module.VectorNdArray() + num_arrays = 1000 + + start_time = time.time() + for i in range(num_arrays): + arr = np.random.rand(10).astype(np.float64) + vector.append(arr) + total_time = time.time() - start_time + + assert total_time < 5.0 + assert len(vector) == num_arrays + + # Test random access + start_time = time.time() + for _ in range(100): + idx = np.random.randint(0, num_arrays) + _ = vector[idx] + access_time = time.time() - start_time + assert access_time < 1.0 + + def test_repeated_operations_stability(self, idaklu_module): + """Test repeated operations for stability.""" + vector = idaklu_module.VectorNdArray() + test_array = np.array([1.0, 2.0, 3.0]) + + for i in range(1000): + vector.append(test_array.copy()) + + if i % 100 == 0: + retrieved = vector[i] + np.testing.assert_array_equal(retrieved, test_array) + + assert len(vector) == 1000 + From 8dd3ea29c9e53b2a1be6ec80d95d06b52cfc7dca Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Fri, 10 Oct 2025 11:43:15 +0100 Subject: [PATCH 03/20] infra: adds __version__ to package, adds nox to dev dependencies w/ uv backend preference --- pyproject.toml | 1 + src/pybammsolvers/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2fa7408..49f0e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "nox[uv]", "pytest", "pytest-cov", "psutil>=7.1.0", diff --git a/src/pybammsolvers/__init__.py b/src/pybammsolvers/__init__.py index 2b93acb..907d8c0 100644 --- a/src/pybammsolvers/__init__.py +++ b/src/pybammsolvers/__init__.py @@ -1,5 +1,6 @@ import importlib.util as il +from .version import __version__ idaklu_spec = il.find_spec("pybammsolvers.idaklu") idaklu = il.module_from_spec(idaklu_spec) From 90ca76cc2a1338e6172030c107a55921e9e3ffa4 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Fri, 10 Oct 2025 14:15:46 +0100 Subject: [PATCH 04/20] tests: adds benchmark session, uv backend for nox, updates to unit suite, integration_tests.yml uses nox session, updates docs --- .github/workflows/benchmarks.yml | 49 +++ .github/workflows/integration_tests.yml | 25 +- .gitignore | 4 + README.md | 40 ++- noxfile.py | 209 ++++++++++-- pytest.ini | 2 +- tests/conftest.py | 21 +- tests/pybamm_benchmarks/README.md | 66 ++++ tests/pybamm_benchmarks/__init__.py | 0 tests/pybamm_benchmarks/run_benchmarks.py | 238 ++++++++++++++ .../test_pybamm_performance.py | 170 ++++++++++ tests/test_functions.py | 13 +- tests/test_integration.py | 16 +- tests/test_module.py | 19 +- tests/test_performance.py | 116 ------- tests/test_vectors.py | 305 ------------------ 16 files changed, 781 insertions(+), 512 deletions(-) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 tests/pybamm_benchmarks/README.md create mode 100644 tests/pybamm_benchmarks/__init__.py create mode 100755 tests/pybamm_benchmarks/run_benchmarks.py create mode 100644 tests/pybamm_benchmarks/test_pybamm_performance.py delete mode 100644 tests/test_performance.py delete mode 100644 tests/test_vectors.py diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..a865513 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,49 @@ +name: Benchmarks + +on: + pull_request: + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest] + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Install dependencies (macOS) + if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-latest' + env: + HOMEBREW_NO_INSTALL_CLEANUP: 1 + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_COLOR: 1 + NONINTERACTIVE: 1 + run: | + brew analytics off + brew install libomp + brew reinstall gcc + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install nox + run: | + pip install nox + + - name: Run benchmarks + run: | + nox -s benchmarks diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index aa9ebbc..ac8c6c0 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -39,28 +39,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Clone PyBaMM repository - uses: actions/checkout@v5 - with: - repository: pybamm-team/PyBaMM - path: PyBaMM - fetch-depth: 0 - fetch-tags: true - - name: Build and test run: | - # Install PyBaMM with dependencies - cd ./PyBaMM - pip install -e ".[all,dev,jax]" - cd .. - - # Replace PyBaMM solvers - pip uninstall pybammsolvers --yes - python install_KLU_Sundials.py - pip install "numpy>=2" --upgrade - pip install . - - # Run pybamm tests - cd ./PyBaMM - pytest tests/unit - pytest tests/integration + # Use PyBaMM-tests nox session + nox -s pybamm-tests diff --git a/.gitignore b/.gitignore index 2b30dd3..8617aca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ build # Extra directories for local work workspace deploy +PyBaMM + +# benchmark results +performance_results.json \ No newline at end of file diff --git a/README.md b/README.md index 33ac435..b240f66 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,13 @@ pip install pybammsolvers The following solvers are available: - PyBaMM's IDAKLU solver -## Local builds +## Development + +### Local builds For testing new solvers and unsupported architectures, local builds are possible. -### Nox +#### Nox (Recommended) Nox can be used to do a quick build: ```bash @@ -26,7 +28,7 @@ nox ``` This will setup an environment and attempt to build the library. -### MacOS +#### MacOS Mac dependencies can be installed using brew ```bash @@ -37,7 +39,7 @@ python install_KLU_Sundials.py pip install . ``` -### Linux +#### Linux Linux installs may vary based on the distribution, however, the basic build can be performed with the following commands: @@ -48,3 +50,33 @@ pip install cmake casadi setuptools wheel "pybind11[global]" python install_KLU_Sundials.py pip install . ``` + +### Testing + +The project includes comprehensive test suites: + +#### Unit Tests +Test pybammsolvers functionality in isolation: +```bash +nox -s unit # Run all unit tests +nox -s integration # Run all integration tests +``` + +#### PyBaMM Integration Tests +Verify compatibility with PyBaMM: +```bash +nox -s pybamm-tests # Clone/update PyBaMM and run all tests +nox -s pybamm-tests -- --unit-only # Run only unit tests +nox -s pybamm-tests -- --integration-only # Run only integration tests +nox -s pybamm-tests -- --no-update # Skip git pull (use current version) +nox -s pybamm-tests -- --pybamm-dir ./custom/path # Use existing PyBaMM clone +nox -s pybamm-tests -- --branch develop # Use specific branch/tag +``` + +The integration tests ensure that changes to pybammsolvers don't break PyBaMM functionality. + +### Benchmarks +Test for performance regressions against released PyBaMM: +```bash +nox -s benchmarks +``` \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index c8d1d9a..1460409 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,7 +4,7 @@ from pathlib import Path -nox.options.default_venv_backend = "virtualenv" +nox.options.default_venv_backend = "uv|virtualenv" nox.options.reuse_existing_virtualenvs = True if sys.platform != "win32": nox.options.sessions = ["idaklu-requires", "unit"] @@ -54,58 +54,199 @@ def run_unit(session): """Run the full test suite.""" set_environment_variables(PYBAMM_ENV, session=session) session.install("setuptools", silent=False) - session.install("casadi==3.6.7", silent=False) session.install(".[dev]", silent=False) - session.run("pytest", "tests", *session.posargs) + session.run("pytest", "tests", "-m", "unit", *session.posargs) -@nox.session(name="unit-fast") -def run_unit_fast(session): - """Run fast tests only (excluding slow and integration tests).""" +@nox.session(name="coverage") +def run_coverage(session): + """Run tests with coverage reporting.""" set_environment_variables(PYBAMM_ENV, session=session) session.install("setuptools", silent=False) - session.install("casadi==3.6.7", silent=False) session.install(".[dev]", silent=False) + session.install("pytest-cov", silent=False) session.run( - "pytest", "tests", "-m", "not slow and not integration", *session.posargs + "pytest", + "tests", + "--cov=pybammsolvers", + "--cov-report=html", + "--cov-report=term-missing", + *session.posargs, ) -@nox.session(name="unit-integration") -def run_unit_integration(session): - """Run integration tests only.""" +@nox.session(name="integration") +def run_integration(session): + """Run integration tests""" set_environment_variables(PYBAMM_ENV, session=session) + + # Build and install pybammsolvers first + if sys.platform != "win32": + session.run("python", "install_KLU_Sundials.py") + session.install("setuptools", silent=False) - session.install("casadi==3.6.7", silent=False) session.install(".[dev]", silent=False) + + # Install PyBaMM + session.install("pybamm", silent=False) + + # Force PyBaMM to use our local pybammsolvers + session.run("uv", "pip", "uninstall", "pybammsolvers", silent=True) + session.install("-e", ".", "--no-deps", silent=False) + + # Run integration tests session.run("pytest", "tests", "-m", "integration", "-v", *session.posargs) -@nox.session(name="unit-performance") -def run_unit_performance(session): - """Run performance tests.""" +@nox.session(name="benchmarks") +def run_benchmarks(session): + """Run PyBaMM performance benchmarks comparing vanilla PyBaMM vs pybammsolvers. + + This session: + 1. Runs benchmarks with vanilla PyBaMM (baseline) + 2. Installs local pybammsolvers + 3. Re-runs benchmarks with pybammsolvers + 4. Compares results and reports regressions + """ set_environment_variables(PYBAMM_ENV, session=session) + + # Build and install pybammsolvers first (for compilation) + if sys.platform != "win32": + session.run("python", "install_KLU_Sundials.py") + session.install("setuptools", silent=False) - session.install("casadi==3.6.7", silent=False) session.install(".[dev]", silent=False) - # Install optional performance testing dependencies - session.install("psutil", silent=False) - session.run("pytest", "tests/test_performance.py", "-v", *session.posargs) + # Install PyBaMM + session.install("pybamm", silent=False) -@nox.session(name="coverage") -def run_coverage(session): - """Run tests with coverage reporting.""" + # Run the benchmark orchestrator script + session.run("python", "tests/pybamm_benchmarks/run_benchmarks.py", *session.posargs) + + +@nox.session(name="pybamm-tests") +def run_pybamm_tests(session): + """Run PyBaMM's full test suite with local pybammsolvers. + + 1. Clones PyBaMM repository (if not already present) + 2. Updates to latest develop version (unless --no-update is specified) + 3. Installs PyBaMM with all dependencies + 4. Replaces bundled pybammsolvers with local version + 5. Runs PyBaMM's unit and integration tests + + Usage: + nox -s pybamm-tests # Clone/update PyBaMM and run all tests + nox -s pybamm-tests -- --unit-only # Run only unit tests + nox -s pybamm-tests -- --integration-only # Run only integration tests + nox -s pybamm-tests -- --no-update # Skip git pull (use current version) + nox -s pybamm-tests -- --pybamm-dir ./custom/path # Use existing PyBaMM clone + nox -s pybamm-tests -- --branch develop # Use specific branch/tag + """ set_environment_variables(PYBAMM_ENV, session=session) - session.install("setuptools", silent=False) - session.install("casadi==3.6.7", silent=False) - session.install(".[dev]", silent=False) - session.install("pytest-cov", silent=False) - session.run( - "pytest", - "tests", - "--cov=pybammsolvers", - "--cov-report=html", - "--cov-report=term-missing", - *session.posargs, - ) + + # Parse session arguments + pybamm_dir = None + unit_only = False + integration_only = False + no_update = False + branch = "develop" + pytest_args = [] + + i = 0 + while i < len(session.posargs): + arg = session.posargs[i] + if arg == "--pybamm-dir" and i + 1 < len(session.posargs): + pybamm_dir = Path(session.posargs[i + 1]).resolve() + i += 2 + elif arg == "--branch" and i + 1 < len(session.posargs): + branch = session.posargs[i + 1] + i += 2 + elif arg == "--unit-only": + unit_only = True + i += 1 + elif arg == "--integration-only": + integration_only = True + i += 1 + elif arg == "--no-update": + no_update = True + i += 1 + else: + pytest_args.append(arg) + i += 1 + + # Set default PyBaMM directory + if pybamm_dir is None: + pybamm_dir = Path("./PyBaMM").resolve() + + session.log(f"Using PyBaMM directory: {pybamm_dir}") + + # Clone PyBaMM if it doesn't exist + if not pybamm_dir.exists(): + session.log(f"Cloning PyBaMM repository (branch: {branch})...") + session.run( + "git", + "clone", + "--branch", + branch, + "https://github.com/pybamm-team/PyBaMM.git", + str(pybamm_dir), + external=True, + ) + else: + session.log(f"PyBaMM directory already exists at {pybamm_dir}") + + # Update PyBaMM if requested (default behavior) + if not no_update: + session.log("Updating PyBaMM to latest version...") + session.cd(str(pybamm_dir)) + try: + # Fetch latest changes + session.run("git", "fetch", "--all", "--tags", external=True) + # Check if we're on the requested branch, switch if needed + session.run("git", "checkout", branch, external=True) + # Pull latest changes + session.run("git", "pull", "--ff-only", external=True) + session.cd(str(Path(__file__).parent)) + except Exception as e: + session.warn(f"Could not update PyBaMM: {e}") + session.warn("Continuing with current version...") + session.cd(str(Path(__file__).parent)) + else: + session.log("Skipping PyBaMM update (--no-update specified)") + + # Install PyBaMM with all dependencies + session.log("Installing PyBaMM with all dependencies...") + session.cd(str(pybamm_dir)) + session.install("-e", ".[all,dev,jax]", silent=False) + + # Go back to pybammsolvers root + session.cd(str(Path(__file__).parent)) + + # Uninstall bundled pybammsolvers + session.log("Uninstalling bundled pybammsolvers...") + session.run("uv", "pip", "uninstall", "pybammsolvers", silent=False) + + # Build and install local pybammsolvers + session.log("Building and installing local pybammsolvers...") + if sys.platform != "win32": + session.run("python", "install_KLU_Sundials.py") + else: + session.warn("Skipping install_KLU_Sundials.py on Windows") + + # Install local pybammsolvers + session.install(".", silent=False) + + # Run PyBaMM tests + session.cd(str(pybamm_dir)) + + if unit_only: + session.log("Running PyBaMM unit tests...") + session.run("pytest", "tests/unit", *pytest_args) + elif integration_only: + session.log("Running PyBaMM integration tests...") + session.run("pytest", "tests/integration", *pytest_args) + else: + session.log("Running PyBaMM unit tests...") + session.run("pytest", "tests/unit", *pytest_args) + session.log("Running PyBaMM integration tests...") + session.run("pytest", "tests/integration", *pytest_args) diff --git a/pytest.ini b/pytest.ini index 4fb714f..750c505 100644 --- a/pytest.ini +++ b/pytest.ini @@ -12,8 +12,8 @@ python_files = test_*.py python_classes = Test* python_functions = test_* markers = - slow: marks tests as slow (deselect with '-m "not slow"') integration: marks tests as integration tests + benchmark: marks tests as performance benchmarks (run with '-m benchmark') filterwarnings = ignore::UserWarning ignore::DeprecationWarning diff --git a/tests/conftest.py b/tests/conftest.py index 7d06556..07a0092 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,8 @@ def pytest_configure(config): """Configure pytest with custom markers.""" - config.addinivalue_line( - "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" - ) config.addinivalue_line("markers", "integration: marks tests as integration tests") + config.addinivalue_line("markers", "unit: marks tests as unit tests") @pytest.fixture(scope="session") @@ -17,30 +15,19 @@ def idaklu_module(): """Fixture to provide the idaklu module.""" try: import pybammsolvers + return pybammsolvers.idaklu except ImportError as e: pytest.skip(f"Could not import pybammsolvers.idaklu: {e}") -def pytest_collection_modifyitems(config, items): - """Modify test collection to add markers based on test names.""" - for item in items: - # Add slow marker to performance tests - if "performance" in item.name.lower() or "TestVectorPerformance" in str(item.parent): - item.add_marker(pytest.mark.slow) - - # Add integration marker - if "integration" in item.name.lower() or "test_integration" in str(item.fspath): - item.add_marker(pytest.mark.integration) - - @pytest.fixture(autouse=True) def setup_test_environment(): """Set up test environment variables.""" # Ensure consistent backend for any plotting os.environ["MPLBACKEND"] = "Agg" - + # Set encoding for consistent behavior os.environ["PYTHONIOENCODING"] = "utf-8" - + yield diff --git a/tests/pybamm_benchmarks/README.md b/tests/pybamm_benchmarks/README.md new file mode 100644 index 0000000..1ac96aa --- /dev/null +++ b/tests/pybamm_benchmarks/README.md @@ -0,0 +1,66 @@ +# Scripts + +This directory contains utility scripts for development and testing. + +## `run_benchmarks.py` + +Orchestrates performance benchmarking to compare vanilla PyBaMM against pybammsolvers. + +### Usage + +Run via nox (recommended): +```bash +nox -s benchmarks +``` + +Or directly: +```bash +python scripts/run_benchmarks.py +``` + +### How It Works + +1. **Baseline Run**: Uninstalls local pybammsolvers and runs benchmarks with vanilla PyBaMM +2. **Local Install**: Installs the local pybammsolvers package +3. **Current Run**: Re-runs benchmarks with pybammsolvers installed +4. **Comparison**: Compares results and reports any performance regressions +5. **Results**: Saves comparison to `performance_results.json` + +### Output + +The script will: +- Print benchmark times for both runs +- Compare performance between vanilla and pybammsolvers +- Flag regressions >20% as failures +- Save detailed results to `performance_results.json` + +### Exit Codes + +- `0`: Success (no significant regressions) +- `1`: Failure (benchmarks failed or >20% regression detected) + +### Example Output + +``` +[1/4] Running baseline benchmarks with vanilla PyBaMM... +✓ SPM 1-hour discharge: + Average: 2.380s + +[2/4] Installing local pybammsolvers... +✓ Local pybammsolvers installed + +[3/4] Running benchmarks with local pybammsolvers... +✓ SPM 1-hour discharge: + Average: 2.290s + +[4/4] Comparing results... +✓ SPM 1-hour discharge: + Baseline: 2.380s + Current: 2.290s + Change: 0.090s (3.8% faster) + +SUMMARY +============================================================ +✓ No significant regressions detected +``` + diff --git a/tests/pybamm_benchmarks/__init__.py b/tests/pybamm_benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pybamm_benchmarks/run_benchmarks.py b/tests/pybamm_benchmarks/run_benchmarks.py new file mode 100755 index 0000000..05d3b0d --- /dev/null +++ b/tests/pybamm_benchmarks/run_benchmarks.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Orchestrate performance benchmarks comparing vanilla PyBaMM vs pybammsolvers. + +This script: +1. Runs benchmarks with vanilla PyBaMM (baseline) +2. Installs local pybammsolvers +3. Runs benchmarks again with pybammsolvers +4. Compares results and reports any regressions +""" + +import sys +import os +import json +import subprocess +from pathlib import Path +from datetime import datetime + + +def run_command(cmd, cwd=None, env=None): + """Run a command and return the result.""" + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + capture_output=True, + text=True, + ) + return result + + +def run_benchmark_suite(output_file, description): + """Run the benchmark suite and save results.""" + print(f"\n{'=' * 60}") + print(f"Running benchmarks: {description}") + print(f"{'=' * 60}\n") + + # Set environment variable for output file + env = os.environ.copy() + env["BENCHMARK_OUTPUT"] = str(output_file) + + # Run pytest with benchmark marker + result = run_command( + [ + sys.executable, + "-m", + "pytest", + "tests/pybamm_benchmarks", + "-m", + "benchmark", + "-v", + "--tb=short", + ], + env=env, + ) + + if result.returncode != 0: + print("Benchmark run failed!") + print(result.stdout) + print(result.stderr) + return False + + print(result.stdout) + return True + + +def compare_results(baseline_file, current_file): + """Compare baseline and current benchmark results.""" + with open(baseline_file) as f: + baseline = json.load(f) + + with open(current_file) as f: + current = json.load(f) + + print("\n" + "=" * 60) + print("PERFORMANCE COMPARISON: Vanilla PyBaMM vs pybammsolvers") + print("=" * 60) + + print(f"\nBaseline: PyBaMM {baseline.get('pybamm_version', 'unknown')}") + print( + f"Current: PyBaMM {current.get('pybamm_version', 'unknown')} + pybammsolvers {current.get('pybammsolvers_version', 'unknown')}" + ) + + baseline_benchmarks = baseline.get("benchmarks", {}) + current_benchmarks = current.get("benchmarks", {}) + + regressions = [] + improvements = [] + + for name in sorted(baseline_benchmarks.keys()): + if name not in current_benchmarks: + print(f"\n⚠️ {name}: Not found in current results") + continue + + baseline_time = baseline_benchmarks[name].get("average") + current_time = current_benchmarks[name].get("average") + + if baseline_time is None or current_time is None: + print(f"\n⚠️ {name}: Missing timing data") + continue + + diff = current_time - baseline_time + pct_change = (diff / baseline_time) * 100 + + status = "⚠️" if abs(pct_change) > 10 else "✓" + direction = "slower" if diff > 0 else "faster" + + print(f"\n{status} {name}:") + print(f" Baseline: {baseline_time:.3f}s") + print(f" Current: {current_time:.3f}s") + print(f" Change: {abs(diff):.3f}s ({abs(pct_change):.1f}% {direction})") + + if pct_change > 20: + regressions.append((name, pct_change)) + print(" ⚠️ WARNING: >20% performance regression!") + elif pct_change < -20: + improvements.append((name, abs(pct_change))) + print(" ✓ Great! >20% performance improvement!") + + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + + if regressions: + print(f"\n⚠️ {len(regressions)} REGRESSION(S) DETECTED:") + for name, pct in regressions: + print(f" - {name}: {pct:.1f}% slower") + return False + + if improvements: + print(f"\n✓ {len(improvements)} IMPROVEMENT(S):") + for name, pct in improvements: + print(f" - {name}: {pct:.1f}% faster") + + print("\n✓ No significant regressions detected") + return True + + +def main(): + """Main orchestration function.""" + print("=" * 60) + print("PyBaMM Performance Benchmark Suite") + print("=" * 60) + + repo_root = Path(__file__).parent.parent.parent + baseline_results = repo_root / "baseline_results.json" + current_results = repo_root / "current_results.json" + + # Step 1: Run baseline benchmarks (vanilla PyBaMM) + print("\n[1/4] Running baseline benchmarks with vanilla PyBaMM...") + print(" (This establishes performance without pybammsolvers)") + + # Uninstall local pybammsolvers if present + print("\n Uninstalling local pybammsolvers...") + result = run_command( + [sys.executable, "-m", "uv", "pip", "uninstall", "-y", "pybammsolvers"] + ) + + # Install PyBaMM's bundled pybammsolvers + print(" Installing pybamm (with bundled pybammsolvers)...") + result = run_command( + [sys.executable, "-m", "uv", "pip", "install", "--force-reinstall", "pybamm"] + ) + + if not run_benchmark_suite(baseline_results, "Baseline (vanilla PyBaMM)"): + print("\n❌ Baseline benchmark suite failed!") + return 1 + + # Step 2: Install local pybammsolvers + print("\n[2/4] Installing local pybammsolvers...") + result = run_command( + [sys.executable, "-m", "uv", "pip", "install", "-e", ".", "--no-deps"], + cwd=repo_root, + ) + + if result.returncode != 0: + print("❌ Failed to install local pybammsolvers!") + print(result.stderr) + return 1 + + print("✓ Local pybammsolvers installed") + + # Step 3: Run current benchmarks (with local pybammsolvers) + print("\n[3/4] Running benchmarks with local pybammsolvers...") + + if not run_benchmark_suite(current_results, "Current (with pybammsolvers)"): + print("\n❌ Current benchmark suite failed!") + return 1 + + # Step 4: Compare results + print("\n[4/4] Comparing results...") + + success = compare_results(baseline_results, current_results) + + # Save comparison results + comparison_file = repo_root / "performance_results.json" + + # Load results + with open(baseline_results) as f: + baseline = json.load(f) + with open(current_results) as f: + current = json.load(f) + + # Create comparison record + comparison = { + "timestamp": datetime.now().isoformat(), + "baseline": baseline, + "current": current, + "success": success, + } + + # Load history if exists + if comparison_file.exists(): + with open(comparison_file) as f: + try: + history = json.load(f) + if not isinstance(history, list): + history = [history] + except json.JSONDecodeError: + history = [] + else: + history = [] + + # Append and save + history.append(comparison) + with open(comparison_file, "w") as f: + json.dump(history, f, indent=2) + + print(f"\n✓ Results saved to {comparison_file}") + + # Clean up temporary files + baseline_results.unlink(missing_ok=True) + current_results.unlink(missing_ok=True) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/pybamm_benchmarks/test_pybamm_performance.py b/tests/pybamm_benchmarks/test_pybamm_performance.py new file mode 100644 index 0000000..d54602c --- /dev/null +++ b/tests/pybamm_benchmarks/test_pybamm_performance.py @@ -0,0 +1,170 @@ +"""Performance benchmarks for PyBaMM integration. + +These tests measure the performance of PyBaMM simulations. +They can be run with vanilla PyBaMM or with pybammsolvers installed. +Results are saved to JSON for comparison by the benchmark orchestrator. +""" + +import time +import json +import os +from pathlib import Path +from datetime import datetime +import pytest + +# Check if PyBaMM is available +pytest.importorskip("pybamm", reason="PyBaMM not installed") +import pybamm + +# Try to import pybammsolvers (may not be installed for baseline run) +try: + import pybammsolvers + + PYBAMMSOLVERS_VERSION = pybammsolvers.__version__ +except (ImportError, AttributeError): + PYBAMMSOLVERS_VERSION = None + + +# Pytest marker for benchmark tests +pytestmark = pytest.mark.benchmark + + +def time_function(func, num_runs=5): + """Time a function execution over multiple runs.""" + times = [] + for i in range(num_runs): + start = time.perf_counter() + func() + end = time.perf_counter() + elapsed = end - start + times.append(elapsed) + print(f" Run {i + 1}/{num_runs}: {elapsed:.3f}s") + + avg = sum(times) / len(times) + return { + "average": avg, + "min": min(times), + "max": max(times), + "runs": times, + } + + +@pytest.fixture(scope="session") +def performance_results(): + """Session-scoped fixture to collect all performance results.""" + results = { + "timestamp": datetime.now().isoformat(), + "pybamm_version": pybamm.__version__, + "pybammsolvers_version": PYBAMMSOLVERS_VERSION, + "benchmarks": {}, + } + yield results + + # Save results at end of session + # Check for environment variable first (set by orchestrator) + output_file = os.environ.get("BENCHMARK_OUTPUT") + if output_file: + output_path = Path(output_file) + else: + output_path = Path("performance_results.json") + + # Save results + with open(output_path, "w") as f: + json.dump(results, f, indent=2) + + print(f"\n{'=' * 60}") + print(f"Performance results saved to {output_path}") + print(f"{'=' * 60}") + + +def record_benchmark(test_name, timing_results, performance_results): + """Record benchmark results.""" + performance_results["benchmarks"][test_name] = timing_results + + print(f"\n✓ {test_name}:") + print(f" Average: {timing_results['average']:.3f}s") + print(f" Min: {timing_results['min']:.3f}s, Max: {timing_results['max']:.3f}s") + + +def test_spm_discharge(performance_results): + """Benchmark SPM discharge simulation.""" + + def run_benchmark(): + model = pybamm.lithium_ion.SPM() + sim = pybamm.Simulation(model, solver=pybamm.IDAKLUSolver()) + sim.solve([0, 3600]) + + timing_results = time_function(run_benchmark) + record_benchmark("SPM 1-hour discharge", timing_results, performance_results) + + +def test_spm_long_discharge(performance_results): + """Benchmark longer SPM discharge.""" + + def run_benchmark(): + model = pybamm.lithium_ion.SPM() + sim = pybamm.Simulation(model, solver=pybamm.IDAKLUSolver()) + sim.solve([0, 10800]) # 3 hours + + timing_results = time_function(run_benchmark) + record_benchmark("SPM 3-hour discharge", timing_results, performance_results) + + +def test_spme_discharge(performance_results): + """Benchmark SPMe discharge simulation.""" + + def run_benchmark(): + model = pybamm.lithium_ion.SPMe() + sim = pybamm.Simulation(model, solver=pybamm.IDAKLUSolver()) + sim.solve([0, 3600]) + + timing_results = time_function(run_benchmark) + record_benchmark("SPMe 1-hour discharge", timing_results, performance_results) + + +def test_dfn_discharge(performance_results): + """Benchmark DFN discharge simulation.""" + + def run_benchmark(): + model = pybamm.lithium_ion.DFN() + sim = pybamm.Simulation(model, solver=pybamm.IDAKLUSolver(atol=1e-6, rtol=1e-6)) + sim.solve([0, 1800]) + + timing_results = time_function(run_benchmark) + record_benchmark("DFN 30-min discharge", timing_results, performance_results) + + +def test_experiment(performance_results): + """Benchmark experiment simulation.""" + + def run_benchmark(): + model = pybamm.lithium_ion.SPM() + experiment = pybamm.Experiment( + [ + "Discharge at 1C for 30 minutes", + "Rest for 10 minutes", + "Charge at 0.5C for 30 minutes", + ] + ) + sim = pybamm.Simulation( + model, experiment=experiment, solver=pybamm.IDAKLUSolver() + ) + sim.solve() + + timing_results = time_function(run_benchmark) + record_benchmark("Simple experiment", timing_results, performance_results) + + +def test_multiple_solves(performance_results): + """Benchmark multiple sequential solves.""" + + def run_benchmark(): + model = pybamm.lithium_ion.SPM() + solver = pybamm.IDAKLUSolver() + + for _ in range(10): + sim = pybamm.Simulation(model, solver=solver) + sim.solve([0, 1800]) + + timing_results = time_function(run_benchmark) + record_benchmark("10 sequential SPM solves", timing_results, performance_results) diff --git a/tests/test_functions.py b/tests/test_functions.py index f61d067..8b1433e 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -11,6 +11,8 @@ class TestObserveFunction: """Test the observe function.""" + pytestmark = pytest.mark.unit + def test_observe_exists(self, idaklu_module): """Test that observe function exists.""" assert hasattr(idaklu_module, "observe") @@ -32,6 +34,8 @@ def test_observe_with_empty_arrays(self, idaklu_module): class TestObserveHermiteInterpFunction: """Test the observe_hermite_interp function.""" + pytestmark = pytest.mark.unit + def test_observe_hermite_interp_exists(self, idaklu_module): """Test that observe_hermite_interp function exists.""" assert hasattr(idaklu_module, "observe_hermite_interp") @@ -54,6 +58,8 @@ def test_observe_hermite_interp_with_empty_arrays(self, idaklu_module): class TestGenerateFunction: """Test the generate_function.""" + pytestmark = pytest.mark.unit + def test_generate_function_exists(self, idaklu_module): """Test that generate_function exists.""" assert hasattr(idaklu_module, "generate_function") @@ -73,6 +79,8 @@ def test_generate_function_with_invalid_input(self, idaklu_module): class TestCreateCasadiSolverGroup: """Test the create_casadi_solver_group function.""" + pytestmark = pytest.mark.unit + def test_create_casadi_solver_group_exists(self, idaklu_module): """Test that create_casadi_solver_group function exists.""" assert hasattr(idaklu_module, "create_casadi_solver_group") @@ -87,6 +95,8 @@ def test_create_casadi_solver_group_without_parameters(self, idaklu_module): class TestCreateIdakluJax: """Test the create_idaklu_jax function.""" + pytestmark = pytest.mark.unit + def test_create_idaklu_jax_exists(self, idaklu_module): """Test that create_idaklu_jax function exists.""" assert hasattr(idaklu_module, "create_idaklu_jax") @@ -101,6 +111,8 @@ def test_create_idaklu_jax_callable(self, idaklu_module): class TestRegistrationsFunction: """Test the registrations function.""" + pytestmark = pytest.mark.unit + def test_registrations_exists(self, idaklu_module): """Test that registrations function exists.""" assert hasattr(idaklu_module, "registrations") @@ -110,4 +122,3 @@ def test_registrations_callable(self, idaklu_module): """Test that registrations function can be called.""" result = idaklu_module.registrations() assert result is not None - diff --git a/tests/test_integration.py b/tests/test_integration.py index 69116b8..d1ea568 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,7 +12,8 @@ class TestVectorIntegration: """Test integration between different vector types and operations.""" - @pytest.mark.integration + pytestmark = pytest.mark.integration + def test_mixed_vector_operations(self, idaklu_module): """Test mixed operations with different array types.""" nd_vector = idaklu_module.VectorNdArray() @@ -38,7 +39,8 @@ def test_mixed_vector_operations(self, idaklu_module): class TestErrorRecovery: """Test error handling and recovery in integration scenarios.""" - @pytest.mark.integration + pytestmark = pytest.mark.integration + def test_partial_failure_recovery(self, idaklu_module): """Test recovery from partial failures.""" vector = idaklu_module.VectorNdArray() @@ -65,8 +67,6 @@ def test_partial_failure_recovery(self, idaklu_module): vector.append(np.array([5.0, 6.0])) assert len(vector) == 3 - @pytest.mark.integration - @pytest.mark.slow def test_large_data_handling(self, idaklu_module): """Test handling of moderately large datasets.""" vector = idaklu_module.VectorNdArray() @@ -89,7 +89,8 @@ def test_large_data_handling(self, idaklu_module): class TestMemoryManagement: """Test memory management in integration scenarios.""" - @pytest.mark.integration + pytestmark = pytest.mark.integration + def test_memory_cleanup_basic(self, idaklu_module): """Test that memory is properly cleaned up.""" vectors = [] @@ -108,7 +109,6 @@ def test_memory_cleanup_basic(self, idaklu_module): # If we get here without crashing, memory management is working - @pytest.mark.integration def test_solution_vector_memory_cleanup(self, idaklu_module): """Test memory cleanup for solution vectors.""" vectors = [] @@ -125,11 +125,11 @@ def test_solution_vector_memory_cleanup(self, idaklu_module): # If we get here without crashing, memory management is working -@pytest.mark.integration -@pytest.mark.slow class TestStressConditions: """Test behavior under stress conditions.""" + pytestmark = pytest.mark.integration + def test_concurrent_access_simulation(self, idaklu_module): """Simulate concurrent access patterns (single-threaded).""" vector = idaklu_module.VectorNdArray() diff --git a/tests/test_module.py b/tests/test_module.py index 7331d03..5625dd1 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -13,9 +13,12 @@ class TestImport: """Test basic module import functionality.""" + pytestmark = pytest.mark.unit + def test_pybammsolvers_import(self): """Test that pybammsolvers can be imported.""" import pybammsolvers + assert pybammsolvers is not None def test_idaklu_module_import(self, idaklu_module): @@ -25,6 +28,7 @@ def test_idaklu_module_import(self, idaklu_module): def test_version_import(self): """Test that version can be imported.""" from pybammsolvers.version import __version__ + assert __version__ is not None assert isinstance(__version__, str) @@ -32,6 +36,8 @@ def test_version_import(self): class TestClasses: """Test that all expected classes are available.""" + pytestmark = pytest.mark.unit + def test_solver_group_class_exists(self, idaklu_module): """Test that IDAKLUSolverGroup class exists.""" assert hasattr(idaklu_module, "IDAKLUSolverGroup") @@ -61,6 +67,8 @@ def test_function_class_exists(self, idaklu_module): class TestFunctions: """Test that all expected functions are available.""" + pytestmark = pytest.mark.unit + @pytest.mark.parametrize( "func_name", [ @@ -82,6 +90,8 @@ def test_function_exists_and_callable(self, idaklu_module, func_name): class TestDocumentation: """Test module and class documentation.""" + pytestmark = pytest.mark.unit + def test_module_has_docstring(self, idaklu_module): """Test that the idaklu module has documentation.""" assert hasattr(idaklu_module, "__doc__") @@ -124,10 +134,10 @@ def test_help_functionality(self, idaklu_module): def test_function_has_signature(self, idaklu_module, func_name): """Test that functions have reasonable signatures.""" func = getattr(idaklu_module, func_name) - + # Should be callable assert callable(func) - + # Try to get signature (might fail for C++ functions) try: sig = inspect.signature(func) @@ -140,6 +150,8 @@ def test_function_has_signature(self, idaklu_module, func_name): class TestBasicFunctionality: """Test basic functionality that doesn't require complex setup.""" + pytestmark = pytest.mark.unit + def test_registrations_function(self, idaklu_module): """Test that registrations function can be called.""" result = idaklu_module.registrations() @@ -159,6 +171,8 @@ def test_solver_group_creation_without_parameters(self, idaklu_module): class TestErrorHandling: """Test error handling for invalid inputs.""" + pytestmark = pytest.mark.unit + def test_generate_function_with_empty_string(self, idaklu_module): """Test generate_function with empty string.""" with pytest.raises(RuntimeError): @@ -168,4 +182,3 @@ def test_generate_function_with_invalid_expression(self, idaklu_module): """Test generate_function with invalid CasADi expression.""" with pytest.raises(RuntimeError): idaklu_module.generate_function("invalid_casadi_expression") - diff --git a/tests/test_performance.py b/tests/test_performance.py deleted file mode 100644 index 6643da5..0000000 --- a/tests/test_performance.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Performance and memory tests for pybammsolvers. - -These tests check performance characteristics and memory usage. -All tests in this module are marked as slow. -""" - -import pytest -import numpy as np -import time -import gc - - -@pytest.mark.slow -class TestMemoryUsage: - """Test memory usage and potential leaks.""" - - def test_memory_cleanup_with_psutil(self, idaklu_module): - """Test that memory is properly cleaned up using psutil.""" - try: - import psutil - import os - except ImportError: - pytest.skip("psutil not available for memory testing") - - # Get initial memory usage - process = psutil.Process(os.getpid()) - initial_memory = process.memory_info().rss - - # Create and destroy many objects - for _ in range(100): - vector = idaklu_module.VectorNdArray() - for _ in range(10): - arr = np.random.rand(100).astype(np.float64) - vector.append(arr) - del vector - - # Force garbage collection - gc.collect() - - # Check memory usage after cleanup - final_memory = process.memory_info().rss - memory_increase = final_memory - initial_memory - - # Allow for some memory increase but not excessive - max_allowed_increase = 100 * 1024 # 100 KB - - assert memory_increase < max_allowed_increase, ( - f"Memory increased by {memory_increase / 1024:.1f} KB, " - f"which exceeds limit of {max_allowed_increase / 1024:.1f} KB" - ) - - -@pytest.mark.slow -class TestPerformanceBenchmarks: - """Benchmark performance of key operations.""" - - def test_append_performance_scaling(self, idaklu_module): - """Test that append performance scales reasonably.""" - vector = idaklu_module.VectorNdArray() - - # Test append time doesn't degrade significantly - times = [] - for batch in range(5): - start_time = time.time() - for _ in range(100): - arr = np.random.rand(50).astype(np.float64) - vector.append(arr) - elapsed = time.time() - start_time - times.append(elapsed) - - # Later batches shouldn't be much slower than early ones - # Allow for some variation but check it's not exponential - assert times[-1] < times[0] * 1.2, "Append performance degraded significantly" - - def test_access_performance_scaling(self, idaklu_module): - """Test that access performance doesn't degrade with size.""" - vector = idaklu_module.VectorNdArray() - - # Build up a reasonably large vector - for i in range(1000): - vector.append(np.array([i], dtype=np.float64)) - - # Test access at different points - access_times = [] - for idx in [0, 250, 500, 750, 999]: - start_time = time.time() - for _ in range(100): - _ = vector[idx] - elapsed = time.time() - start_time - access_times.append(elapsed) - - # Access time should be relatively constant (not index-dependent) - avg_time = np.mean(access_times) - for t in access_times: - assert t < avg_time * 1.5, f"Access time {t} significantly differs from average {avg_time}" - - def test_large_array_copy_performance(self, idaklu_module): - """Test performance of copying large arrays.""" - vector = idaklu_module.VectorNdArray() - - # Create a large array - large_array = np.random.rand(10000, 100).astype(np.float64) - - start_time = time.time() - vector.append(large_array) - append_time = time.time() - start_time - - # Should be fast (< 1 second for 1M elements) - assert append_time < 1.0, f"Large array append took {append_time:.3f}s" - - start_time = time.time() - retrieved = vector[0] - retrieval_time = time.time() - start_time - - assert retrieval_time < 1.0, f"Large array retrieval took {retrieval_time:.3f}s" - assert retrieved.shape == large_array.shape diff --git a/tests/test_vectors.py b/tests/test_vectors.py deleted file mode 100644 index 7631a2a..0000000 --- a/tests/test_vectors.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Test vector container classes. - -This module consolidates all tests related to VectorNdArray, -VectorRealtypeNdArray, and VectorSolution classes. -""" - -import pytest -import numpy as np - - -class TestVectorNdArrayBasic: - """Test basic VectorNdArray functionality.""" - - def test_creation(self, idaklu_module): - """Test VectorNdArray can be created.""" - vector = idaklu_module.VectorNdArray() - assert vector is not None - assert len(vector) == 0 - - def test_append_and_access(self, idaklu_module): - """Test appending arrays and accessing them.""" - vector = idaklu_module.VectorNdArray() - - arr1 = np.array([1.0, 2.0, 3.0]) - arr2 = np.array([4.0, 5.0, 6.0]) - - vector.append(arr1) - assert len(vector) == 1 - - vector.append(arr2) - assert len(vector) == 2 - - # Test access - retrieved = vector[0] - np.testing.assert_array_equal(retrieved, arr1) - - retrieved = vector[1] - np.testing.assert_array_equal(retrieved, arr2) - - def test_empty_vector_access_raises_error(self, idaklu_module): - """Test that accessing empty vector raises IndexError.""" - vector = idaklu_module.VectorNdArray() - with pytest.raises(IndexError): - _ = vector[0] - - def test_multiple_arrays_different_shapes(self, idaklu_module): - """Test vector can hold arrays of different shapes.""" - vector = idaklu_module.VectorNdArray() - - arrays = [ - np.array([1.0, 2.0, 3.0]), - np.array([[1, 2], [3, 4]]), - np.ones((5,)), - np.zeros((2, 3)), - ] - - for arr in arrays: - vector.append(arr.astype(np.float64)) - - assert len(vector) == len(arrays) - - # Verify retrieval - for i, original in enumerate(arrays): - retrieved = vector[i] - np.testing.assert_array_equal(retrieved, original.astype(np.float64)) - - -class TestVectorNdArrayEdgeCases: - """Test edge cases for VectorNdArray.""" - - def test_zero_dimensional_arrays(self, idaklu_module): - """Test handling of 0-dimensional arrays.""" - vector = idaklu_module.VectorNdArray() - - scalar_array = np.array(42.0) - assert scalar_array.ndim == 0 - - vector.append(scalar_array) - assert len(vector) == 1 - - retrieved = vector[0] - assert retrieved.shape == () - assert float(retrieved) == 42.0 - - def test_large_dimensions(self, idaklu_module): - """Test arrays with many dimensions but small total size.""" - vector = idaklu_module.VectorNdArray() - - shape = (1, 1, 1, 1, 1, 1, 1, 1, 2) - arr = np.ones(shape, dtype=np.float64) - - vector.append(arr) - assert len(vector) == 1 - - retrieved = vector[0] - assert retrieved.shape == shape - np.testing.assert_array_equal(retrieved, arr) - - def test_special_float_values(self, idaklu_module): - """Test handling of inf and nan.""" - vector = idaklu_module.VectorNdArray() - - # Test with infinity - inf_array = np.array([np.inf, -np.inf, 1.0]) - vector.append(inf_array) - retrieved = vector[0] - assert np.isinf(retrieved[0]) - assert np.isinf(retrieved[1]) - assert retrieved[2] == 1.0 - - # Test with NaN - nan_array = np.array([np.nan, 1.0, 2.0]) - vector.append(nan_array) - retrieved = vector[-1] - assert np.isnan(retrieved[0]) - assert retrieved[1] == 1.0 - - def test_extremely_small_values(self, idaklu_module): - """Test handling of extremely small values.""" - vector = idaklu_module.VectorNdArray() - - tiny_values = np.array([ - np.finfo(np.float64).tiny, - np.finfo(np.float64).eps, - 1e-300, - 0.0, - ]) - - vector.append(tiny_values) - retrieved = vector[0] - np.testing.assert_array_equal(retrieved, tiny_values) - - def test_extremely_large_values(self, idaklu_module): - """Test handling of extremely large values.""" - vector = idaklu_module.VectorNdArray() - - large_values = np.array([ - 1e100, - 1e200, - np.finfo(np.float64).max / 2, - -1e100, - ]) - - vector.append(large_values) - retrieved = vector[0] - np.testing.assert_array_equal(retrieved, large_values) - - def test_negative_indexing(self, idaklu_module): - """Test negative indexing behavior.""" - vector = idaklu_module.VectorNdArray() - - arrays = [np.array([i], dtype=np.float64) for i in range(5)] - for arr in arrays: - vector.append(arr) - - try: - last_elem = vector[-1] - assert last_elem[0] == 4.0 - - second_last = vector[-2] - assert second_last[0] == 3.0 - except (IndexError, TypeError): - pytest.skip("Negative indexing not supported") - - @pytest.mark.slow - def test_maximum_vector_size(self, idaklu_module): - """Test vector behavior near maximum reasonable size.""" - vector = idaklu_module.VectorNdArray() - - max_size = 1000 - for i in range(max_size): - arr = np.array([i], dtype=np.float64) - vector.append(arr) - - if i % 100 == 0 and i > 0: - first_elem = vector[0] - assert first_elem[0] == 0.0 - - assert len(vector) == max_size - - # Test access at key positions - for i in [0, max_size // 2, max_size - 1]: - elem = vector[i] - assert elem[0] == float(i) - - -class TestVectorNdArrayTypeHandling: - """Test type coercion and conversion behavior.""" - - def test_integer_array_coercion(self, idaklu_module): - """Test coercion of integer arrays.""" - vector = idaklu_module.VectorNdArray() - - int_array = np.array([1, 2, 3], dtype=np.int32) - vector.append(int_array) - retrieved = vector[0] - - assert retrieved.dtype in [np.float64, np.float32, np.int32, np.int64] - np.testing.assert_array_equal(retrieved.astype(int), [1, 2, 3]) - - def test_complex_array_handling(self, idaklu_module): - """Test handling of complex arrays.""" - vector = idaklu_module.VectorNdArray() - - complex_array = np.array([1 + 2j, 3 + 4j], dtype=np.complex128) - vector.append(complex_array) - retrieved = vector[0] - assert retrieved is not None - - def test_boolean_array_handling(self, idaklu_module): - """Test handling of boolean arrays.""" - vector = idaklu_module.VectorNdArray() - - bool_array = np.array([True, False, True], dtype=bool) - vector.append(bool_array) - retrieved = vector[0] - assert retrieved.dtype in [bool, np.int32, np.int64, np.float32, np.float64] - - -class TestVectorRealtypeNdArray: - """Test VectorRealtypeNdArray functionality.""" - - def test_creation(self, idaklu_module): - """Test VectorRealtypeNdArray can be created.""" - vector = idaklu_module.VectorRealtypeNdArray() - assert vector is not None - assert len(vector) == 0 - - -class TestVectorSolution: - """Test VectorSolution functionality.""" - - def test_creation(self, idaklu_module): - """Test VectorSolution can be created.""" - vector = idaklu_module.VectorSolution() - assert vector is not None - assert len(vector) == 0 - assert hasattr(vector, "__len__") - - def test_has_append_method(self, idaklu_module): - """Test VectorSolution has append method.""" - vector = idaklu_module.VectorSolution() - assert hasattr(vector, "append") - - -@pytest.mark.slow -class TestVectorPerformance: - """Test performance characteristics of vectors.""" - - def test_large_array_append_retrieval(self, idaklu_module): - """Test performance with large arrays.""" - import time - - vector = idaklu_module.VectorNdArray() - large_array = np.random.rand(1000, 100).astype(np.float64) - - start_time = time.time() - vector.append(large_array) - append_time = time.time() - start_time - assert append_time < 1.0 - - start_time = time.time() - retrieved = vector[0] - retrieval_time = time.time() - start_time - assert retrieval_time < 1.0 - assert retrieved.shape == large_array.shape - - def test_many_small_arrays(self, idaklu_module): - """Test performance with many small arrays.""" - import time - - vector = idaklu_module.VectorNdArray() - num_arrays = 1000 - - start_time = time.time() - for i in range(num_arrays): - arr = np.random.rand(10).astype(np.float64) - vector.append(arr) - total_time = time.time() - start_time - - assert total_time < 5.0 - assert len(vector) == num_arrays - - # Test random access - start_time = time.time() - for _ in range(100): - idx = np.random.randint(0, num_arrays) - _ = vector[idx] - access_time = time.time() - start_time - assert access_time < 1.0 - - def test_repeated_operations_stability(self, idaklu_module): - """Test repeated operations for stability.""" - vector = idaklu_module.VectorNdArray() - test_array = np.array([1.0, 2.0, 3.0]) - - for i in range(1000): - vector.append(test_array.copy()) - - if i % 100 == 0: - retrieved = vector[i] - np.testing.assert_array_equal(retrieved, test_array) - - assert len(vector) == 1000 - From ebffa02449a7502b47e5e9a33ae55268de039267 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 10:17:22 +0100 Subject: [PATCH 05/20] benchmarks: remove icons --- tests/pybamm_benchmarks/README.md | 10 ++++----- tests/pybamm_benchmarks/run_benchmarks.py | 26 +++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/pybamm_benchmarks/README.md b/tests/pybamm_benchmarks/README.md index 1ac96aa..a215066 100644 --- a/tests/pybamm_benchmarks/README.md +++ b/tests/pybamm_benchmarks/README.md @@ -43,24 +43,24 @@ The script will: ``` [1/4] Running baseline benchmarks with vanilla PyBaMM... -✓ SPM 1-hour discharge: +SPM 1-hour discharge: Average: 2.380s [2/4] Installing local pybammsolvers... -✓ Local pybammsolvers installed +Local pybammsolvers installed [3/4] Running benchmarks with local pybammsolvers... -✓ SPM 1-hour discharge: +SPM 1-hour discharge: Average: 2.290s [4/4] Comparing results... -✓ SPM 1-hour discharge: +SPM 1-hour discharge: Baseline: 2.380s Current: 2.290s Change: 0.090s (3.8% faster) SUMMARY ============================================================ -✓ No significant regressions detected +No significant regressions detected ``` diff --git a/tests/pybamm_benchmarks/run_benchmarks.py b/tests/pybamm_benchmarks/run_benchmarks.py index 05d3b0d..7e5dd20 100755 --- a/tests/pybamm_benchmarks/run_benchmarks.py +++ b/tests/pybamm_benchmarks/run_benchmarks.py @@ -88,20 +88,20 @@ def compare_results(baseline_file, current_file): for name in sorted(baseline_benchmarks.keys()): if name not in current_benchmarks: - print(f"\n⚠️ {name}: Not found in current results") + print(f"\n{name}: Not found in current results") continue baseline_time = baseline_benchmarks[name].get("average") current_time = current_benchmarks[name].get("average") if baseline_time is None or current_time is None: - print(f"\n⚠️ {name}: Missing timing data") + print(f"\n{name}: Missing timing data") continue diff = current_time - baseline_time pct_change = (diff / baseline_time) * 100 - status = "⚠️" if abs(pct_change) > 10 else "✓" + status = "WARNING" if abs(pct_change) > 10 else "OK" direction = "slower" if diff > 0 else "faster" print(f"\n{status} {name}:") @@ -111,27 +111,27 @@ def compare_results(baseline_file, current_file): if pct_change > 20: regressions.append((name, pct_change)) - print(" ⚠️ WARNING: >20% performance regression!") + print(" WARNING: >20% performance regression!") elif pct_change < -20: improvements.append((name, abs(pct_change))) - print(" ✓ Great! >20% performance improvement!") + print(" Great! >20% performance improvement!") print("\n" + "=" * 60) print("SUMMARY") print("=" * 60) if regressions: - print(f"\n⚠️ {len(regressions)} REGRESSION(S) DETECTED:") + print(f"\n{len(regressions)} REGRESSION(S) DETECTED:") for name, pct in regressions: print(f" - {name}: {pct:.1f}% slower") return False if improvements: - print(f"\n✓ {len(improvements)} IMPROVEMENT(S):") + print(f"\n{len(improvements)} IMPROVEMENT(S):") for name, pct in improvements: print(f" - {name}: {pct:.1f}% faster") - print("\n✓ No significant regressions detected") + print("\nNo significant regressions detected") return True @@ -162,7 +162,7 @@ def main(): ) if not run_benchmark_suite(baseline_results, "Baseline (vanilla PyBaMM)"): - print("\n❌ Baseline benchmark suite failed!") + print("\nBaseline benchmark suite failed!") return 1 # Step 2: Install local pybammsolvers @@ -173,17 +173,17 @@ def main(): ) if result.returncode != 0: - print("❌ Failed to install local pybammsolvers!") + print("Failed to install local pybammsolvers!") print(result.stderr) return 1 - print("✓ Local pybammsolvers installed") + print("Local pybammsolvers installed") # Step 3: Run current benchmarks (with local pybammsolvers) print("\n[3/4] Running benchmarks with local pybammsolvers...") if not run_benchmark_suite(current_results, "Current (with pybammsolvers)"): - print("\n❌ Current benchmark suite failed!") + print("\nCurrent benchmark suite failed!") return 1 # Step 4: Compare results @@ -225,7 +225,7 @@ def main(): with open(comparison_file, "w") as f: json.dump(history, f, indent=2) - print(f"\n✓ Results saved to {comparison_file}") + print(f"\nResults saved to {comparison_file}") # Clean up temporary files baseline_results.unlink(missing_ok=True) From 9a70d9609c726301a0fcb8556dc0bdbab32eddf1 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 11:18:47 +0100 Subject: [PATCH 06/20] Fix: RPath installation bool, updates PYBAMM_ENV in noxfile, adds nox install to workflow --- .github/workflows/integration_tests.yml | 4 ++++ CMakeLists.txt | 6 ++++-- noxfile.py | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index ac8c6c0..06942fd 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -39,6 +39,10 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install nox + run: | + pip install nox + - name: Build and test run: | # Use PyBaMM-tests nox session diff --git a/CMakeLists.txt b/CMakeLists.txt index 702c194..0fa1b5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,6 +141,8 @@ if (${USE_PYTHON_CASADI}) # This allows finding casadi in the same Python environment at runtime # Module is at: site-packages/pybammsolvers/idaklu.so # CasADi is at: site-packages/casadi/libcasadi.dylib + # SuiteSparse/SUNDIALS are found via DYLD_LIBRARY_PATH/LD_LIBRARY_PATH + # (set by noxfile or user environment) pointing to .idaklu/lib # Note: Windows uses vcpkg with static linking, no RPATH needed if (APPLE) set_target_properties( @@ -148,7 +150,7 @@ if (${USE_PYTHON_CASADI}) BUILD_RPATH "${CASADI_LIB_DIR}" BUILD_RPATH_USE_LINK_PATH FALSE INSTALL_RPATH "@loader_path/../casadi" - BUILD_WITH_INSTALL_RPATH FALSE + BUILD_WITH_INSTALL_RPATH TRUE ) else() set_target_properties( @@ -156,7 +158,7 @@ if (${USE_PYTHON_CASADI}) BUILD_RPATH "${CASADI_LIB_DIR}" BUILD_RPATH_USE_LINK_PATH FALSE INSTALL_RPATH "$ORIGIN/../casadi" - BUILD_WITH_INSTALL_RPATH FALSE + BUILD_WITH_INSTALL_RPATH TRUE ) endif() # Link against casadi by name, not absolute path, to avoid issues with diff --git a/noxfile.py b/noxfile.py index 1460409..bd1e5a5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -11,9 +11,10 @@ else: nox.options.sessions = ["unit"] -homedir = Path(__file__) +homedir = Path(__file__).parent.resolve() PYBAMM_ENV = { "LD_LIBRARY_PATH": f"{homedir}/.idaklu/lib", + "DYLD_LIBRARY_PATH": f"{homedir}/.idaklu/lib", "PYTHONIOENCODING": "utf-8", "MPLBACKEND": "Agg", # Expression evaluators (...EXPR_CASADI cannot be fully disabled at this time) From 11cebde991fd44c3a534d92480c9c075f29bb0d9 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 11:46:40 +0100 Subject: [PATCH 07/20] test: try condition RPATH --- CMakeLists.txt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0fa1b5a..6f2de48 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,13 +144,24 @@ if (${USE_PYTHON_CASADI}) # SuiteSparse/SUNDIALS are found via DYLD_LIBRARY_PATH/LD_LIBRARY_PATH # (set by noxfile or user environment) pointing to .idaklu/lib # Note: Windows uses vcpkg with static linking, no RPATH needed + + # For CI wheel builds, use BUILD_WITH_INSTALL_RPATH=FALSE so that + # wheel repair tools (delocate/auditwheel) can properly analyze dependencies. + # For local development, use BUILD_WITH_INSTALL_RPATH=TRUE so the module + # works immediately after pip install without wheel repair. + if(DEFINED ENV{CIBUILDWHEEL}) + set(USE_INSTALL_RPATH_AT_BUILD FALSE) + else() + set(USE_INSTALL_RPATH_AT_BUILD TRUE) + endif() + if (APPLE) set_target_properties( idaklu PROPERTIES BUILD_RPATH "${CASADI_LIB_DIR}" BUILD_RPATH_USE_LINK_PATH FALSE INSTALL_RPATH "@loader_path/../casadi" - BUILD_WITH_INSTALL_RPATH TRUE + BUILD_WITH_INSTALL_RPATH ${USE_INSTALL_RPATH_AT_BUILD} ) else() set_target_properties( @@ -158,7 +169,7 @@ if (${USE_PYTHON_CASADI}) BUILD_RPATH "${CASADI_LIB_DIR}" BUILD_RPATH_USE_LINK_PATH FALSE INSTALL_RPATH "$ORIGIN/../casadi" - BUILD_WITH_INSTALL_RPATH TRUE + BUILD_WITH_INSTALL_RPATH ${USE_INSTALL_RPATH_AT_BUILD} ) endif() # Link against casadi by name, not absolute path, to avoid issues with From 680e5020589ee1ce5448695b103aa425153be623 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 11:54:16 +0100 Subject: [PATCH 08/20] fix: precommit, integration workflow missing uv --- .github/workflows/integration_tests.yml | 6 +++++- tests/test_integration.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 06942fd..ac0a242 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -39,9 +39,13 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install uv + run: | + pip install uv + - name: Install nox run: | - pip install nox + uv pip install nox - name: Build and test run: | diff --git a/tests/test_integration.py b/tests/test_integration.py index d1ea568..9036106 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -72,7 +72,7 @@ def test_large_data_handling(self, idaklu_module): vector = idaklu_module.VectorNdArray() large_arrays = [] - for i in range(10): + for _i in range(10): arr = np.random.rand(100, 50).astype(np.float64) large_arrays.append(arr) vector.append(arr) From ccddae15e686c5b7634d1963cf46a52332fa7e9f Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 12:07:32 +0100 Subject: [PATCH 09/20] benchmarks: update regression threshold, increase repeats, try: fix windows build --- CMakeLists.txt | 1 + tests/pybamm_benchmarks/run_benchmarks.py | 4 ++-- tests/pybamm_benchmarks/test_pybamm_performance.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f2de48..fd43f39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,6 +181,7 @@ if (${USE_PYTHON_CASADI}) else () message("Trying to link against any casadi package apart from the Python one") find_package(casadi CONFIG REQUIRED) + target_link_libraries(idaklu PRIVATE casadi) endif () # openmp diff --git a/tests/pybamm_benchmarks/run_benchmarks.py b/tests/pybamm_benchmarks/run_benchmarks.py index 7e5dd20..8f15086 100755 --- a/tests/pybamm_benchmarks/run_benchmarks.py +++ b/tests/pybamm_benchmarks/run_benchmarks.py @@ -109,9 +109,9 @@ def compare_results(baseline_file, current_file): print(f" Current: {current_time:.3f}s") print(f" Change: {abs(diff):.3f}s ({abs(pct_change):.1f}% {direction})") - if pct_change > 20: + if pct_change > 50: regressions.append((name, pct_change)) - print(" WARNING: >20% performance regression!") + print(" WARNING: >50% performance regression!") elif pct_change < -20: improvements.append((name, abs(pct_change))) print(" Great! >20% performance improvement!") diff --git a/tests/pybamm_benchmarks/test_pybamm_performance.py b/tests/pybamm_benchmarks/test_pybamm_performance.py index d54602c..af331bc 100644 --- a/tests/pybamm_benchmarks/test_pybamm_performance.py +++ b/tests/pybamm_benchmarks/test_pybamm_performance.py @@ -29,7 +29,7 @@ pytestmark = pytest.mark.benchmark -def time_function(func, num_runs=5): +def time_function(func, num_runs=20): """Time a function execution over multiple runs.""" times = [] for i in range(num_runs): From 046e98e2e1fbe3d1be71496868312b71558c0530 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 13:20:05 +0100 Subject: [PATCH 10/20] fix: integration workflow --- .github/workflows/integration_tests.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index ac0a242..c852f15 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -39,13 +39,9 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install uv + - name: Install nox, uv run: | - pip install uv - - - name: Install nox - run: | - uv pip install nox + pip install nox uv - name: Build and test run: | From 6abe878fc17884ed907357a72f26468d8abbdc68 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 13:36:07 +0100 Subject: [PATCH 11/20] CI: unifi package management --- .github/workflows/benchmarks.yml | 6 +++--- .github/workflows/integration_tests.yml | 6 +++--- .github/workflows/unit_tests.yml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index a865513..ec494cc 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -40,10 +40,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install nox + - name: Install uv run: | - pip install nox + pip install uv - name: Run benchmarks run: | - nox -s benchmarks + uvx nox -s benchmarks diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index c852f15..9a4d779 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -39,11 +39,11 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install nox, uv + - name: Install uv run: | - pip install nox uv + pip install uv - name: Build and test run: | # Use PyBaMM-tests nox session - nox -s pybamm-tests + uvx nox -s pybamm-tests diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7ba459a..79b12cf 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -44,10 +44,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install nox + - name: Install uv run: | - pip install nox + pip install uv - name: Build and test run: | - nox + uvx nox From 184383c5aadb6f783b014f19873112fde1adaee7 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 13 Oct 2025 13:51:52 +0100 Subject: [PATCH 12/20] benchmarks: timeit implementation --- .../pybamm_benchmarks/test_pybamm_performance.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/pybamm_benchmarks/test_pybamm_performance.py b/tests/pybamm_benchmarks/test_pybamm_performance.py index af331bc..ba4bd90 100644 --- a/tests/pybamm_benchmarks/test_pybamm_performance.py +++ b/tests/pybamm_benchmarks/test_pybamm_performance.py @@ -5,7 +5,7 @@ Results are saved to JSON for comparison by the benchmark orchestrator. """ -import time +import timeit import json import os from pathlib import Path @@ -24,25 +24,15 @@ except (ImportError, AttributeError): PYBAMMSOLVERS_VERSION = None - # Pytest marker for benchmark tests pytestmark = pytest.mark.benchmark def time_function(func, num_runs=20): """Time a function execution over multiple runs.""" - times = [] - for i in range(num_runs): - start = time.perf_counter() - func() - end = time.perf_counter() - elapsed = end - start - times.append(elapsed) - print(f" Run {i + 1}/{num_runs}: {elapsed:.3f}s") - - avg = sum(times) / len(times) + times = timeit.repeat(func, repeat=5, number=num_runs) return { - "average": avg, + "average": sum(times) / len(times), "min": min(times), "max": max(times), "runs": times, From afd14d358365613ecfe75cfef457975ab9bd7499 Mon Sep 17 00:00:00 2001 From: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> Date: Wed, 15 Oct 2025 08:50:42 +0100 Subject: [PATCH 13/20] test: adds casadi-based integration tests, removes simplistic unit tests (#70) --- noxfile.py | 2 +- tests/conftest.py | 243 ++++++++++++++++++++++++++ tests/test_functions.py | 124 ------------- tests/test_integration.py | 353 ++++++++++++++++++++------------------ tests/test_module.py | 197 ++++----------------- 5 files changed, 463 insertions(+), 456 deletions(-) delete mode 100644 tests/test_functions.py diff --git a/noxfile.py b/noxfile.py index bd1e5a5..23cd4e6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -96,7 +96,7 @@ def run_integration(session): session.install("-e", ".", "--no-deps", silent=False) # Run integration tests - session.run("pytest", "tests", "-m", "integration", "-v", *session.posargs) + session.run("pytest", "tests", "-m", "integration", *session.posargs) @nox.session(name="benchmarks") diff --git a/tests/conftest.py b/tests/conftest.py index 07a0092..6b86d9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,17 @@ """Pytest configuration and fixtures for pybammsolvers tests.""" +from __future__ import annotations + import pytest import os +import numpy as np + +try: + import casadi + + CASADI_AVAILABLE = True +except ImportError: + CASADI_AVAILABLE = False def pytest_configure(config): @@ -31,3 +41,236 @@ def setup_test_environment(): os.environ["PYTHONIOENCODING"] = "utf-8" yield + + +@pytest.fixture(scope="session") +def exponential_decay_model(): + """ + Fixture providing an exponential decay ODE model. + + The model is: dy/dt = -k*y, with exact solution y(t) = y0 * exp(-k*t) + + Returns a dictionary containing: + - 'k': decay constant + - 'y0': initial condition + - 't_eval': evaluation times + - 'exact_solution': function to compute exact solution at any time + """ + if not CASADI_AVAILABLE: + pytest.skip("CasADi not available") + + # Model parameters + k = 0.5 # decay constant + y0 = 1.0 # initial condition + t_eval = np.linspace(0, 5, 50) + + def exact_solution(t): + """Compute exact solution at time t.""" + return y0 * np.exp(-k * t) + + return { + "k": k, + "y0": y0, + "t_eval": t_eval, + "exact_solution": exact_solution, + } + + +@pytest.fixture(scope="function") +def exponential_decay_solver(idaklu_module, exponential_decay_model): + """ + Fixture providing a configured solver for exponential decay model. + + Sets up a complete IDAKLU solver instance with the exponential decay + ODE system ready to solve. + """ + if not CASADI_AVAILABLE: + pytest.skip("CasADi not available") + + # Model parameters + k = exponential_decay_model["k"] + y0 = exponential_decay_model["y0"] + + # Problem dimensions + n_states = 1 + n_inputs = 1 + n_sensitivity_params = 0 + + # Create symbolic variables + t_sym = casadi.MX.sym("t") + y_sym = casadi.MX.sym("y", n_states) + p_sym = casadi.MX.sym("p", n_inputs) + + # RHS function: For ODE dy/dt = -k*y + rhs = casadi.vertcat(-p_sym[0] * y_sym[0]) + + # Create RHS function: t, y, inputs + rhs_alg = casadi.Function("rhs_alg", [t_sym, y_sym, p_sym], [rhs]) + + # Jacobian: PyBaMM computes jac_rhs - cj * mass_matrix + # For rhs = -k*y: jac_rhs = d(rhs)/dy = -k + # mass_matrix = 1 (identity for ODE) + # So: jac_times_cjmass = -k - cj * 1 = -k - cj + cj_sym = casadi.MX.sym("cj") + jac_result = casadi.vertcat(-p_sym[0] - cj_sym) + jac_times_cjmass = casadi.Function( + "jac_times_cjmass", [t_sym, y_sym, p_sym, cj_sym], [jac_result] + ) + + # Sparse matrix structure (single element for 1x1 system) + jac_times_cjmass_colptrs = np.array([0, 1], dtype=np.int64) + jac_times_cjmass_rowvals = np.array([0], dtype=np.int64) + jac_times_cjmass_nnz = 1 + + # Define vector symbol for matrix-vector products + v_sym = casadi.MX.sym("v", n_states) + + # Mass matrix action: M @ v (identity for ODE, so returns v) + mass_action = casadi.Function("mass", [v_sym], [v_sym]) + + # Jacobian action (for matrix-free methods): d(rhs)/dy @ v + # For rhs = -k*y: d(rhs)/dy = -k, so jac_action = -k * v + jac_action_result = casadi.vertcat(-p_sym[0] * v_sym[0]) + jac_action = casadi.Function( + "jac_action", [t_sym, y_sym, p_sym, v_sym], [jac_action_result] + ) + + # Sensitivity equations + if n_sensitivity_params > 0: + sens = casadi.Function( + "sens", + [t_sym, y_sym, p_sym], + [casadi.MX.zeros(n_states, n_sensitivity_params)], + ) + else: + # Empty function for no sensitivities + sens = casadi.Function("sens", [], []) + + # No events - signature is [t, y, inputs] + events = casadi.Function("events", [t_sym, y_sym, p_sym], [casadi.MX(0)]) + n_events = 0 + + # DAE identifier (1 = differential, 0 = algebraic) + # For ODE, all states are differential + rhs_alg_id = np.array([1.0], dtype=np.float64) + + # Tolerances + atol = np.array([1e-8], dtype=np.float64) + rtol = 1e-8 + + # Output variables (just return the state itself as a vector) + var_fcn = casadi.Function("var", [t_sym, y_sym, p_sym], [casadi.vertcat(y_sym[0])]) + var_fcns = [idaklu_module.generate_function(var_fcn.serialize())] + + # Sensitivities of output wrt states and params + if n_sensitivity_params > 0: + dvar_dy_fcn = casadi.Function( + "dvar_dy", [t_sym, y_sym, p_sym], [casadi.MX.ones(1, n_states)] + ) + dvar_dy_fcns = [idaklu_module.generate_function(dvar_dy_fcn.serialize())] + + dvar_dp_fcn = casadi.Function( + "dvar_dp", [t_sym, y_sym, p_sym], [casadi.MX.zeros(1, n_sensitivity_params)] + ) + dvar_dp_fcns = [idaklu_module.generate_function(dvar_dp_fcn.serialize())] + else: + dvar_dy_fcns = [] + dvar_dp_fcns = [] + + # Convert CasADi functions to idaklu Function objects + rhs_alg_func = idaklu_module.generate_function(rhs_alg.serialize()) + jac_times_cjmass_func = idaklu_module.generate_function( + jac_times_cjmass.serialize() + ) + jac_action_func = idaklu_module.generate_function(jac_action.serialize()) + mass_action_func = idaklu_module.generate_function(mass_action.serialize()) + sens_func = idaklu_module.generate_function(sens.serialize()) + events_func = idaklu_module.generate_function(events.serialize()) + + # Solver options + options = { + # SetupOptions + "jacobian": "sparse", + "preconditioner": "none", + "precon_half_bandwidth": 5, + "precon_half_bandwidth_keep": 5, + "num_threads": 1, + "num_solvers": 1, + "linear_solver": "SUNLinSol_KLU", + "linsol_max_iterations": 5, + # SolverOptions + "print_stats": False, + "max_order_bdf": 5, + "max_num_steps": 10000, + "dt_init": 0.01, + "dt_min": 0.0, + "dt_max": 0.1, + "max_error_test_failures": 100, + "max_nonlinear_iterations": 10, + "max_convergence_failures": 10, + "nonlinear_convergence_coefficient": 0.33, + "nonlinear_convergence_coefficient_ic": 0.0033, + "suppress_algebraic_error": False, + "hermite_interpolation": True, + "calc_ic": False, # We provide consistent initial conditions + "init_all_y_ic": False, + "max_num_steps_ic": 5, + "max_num_jacobians_ic": 4, + "max_num_iterations_ic": 10, + "max_linesearch_backtracks_ic": 100, + "linesearch_off_ic": False, + "linear_solution_scaling": True, + "epsilon_linear_tolerance": 0.05, + "increment_factor": 1.0, + } + + # Bandwidths (for banded solvers, not used with KLU) + jac_bandwidth_lower = 0 + jac_bandwidth_upper = 0 + + # Create solver group + solver = idaklu_module.create_casadi_solver_group( + n_states, + n_sensitivity_params, + rhs_alg_func, + jac_times_cjmass_func, + jac_times_cjmass_colptrs, + jac_times_cjmass_rowvals, + jac_times_cjmass_nnz, + jac_bandwidth_lower, + jac_bandwidth_upper, + jac_action_func, + mass_action_func, + sens_func, + events_func, + n_events, + rhs_alg_id, + atol, + rtol, + n_inputs, + var_fcns, + dvar_dy_fcns, + dvar_dp_fcns, + options, + ) + + # Initial conditions need to be 2D: [number_of_groups, n_coeffs] + # where n_coeffs = number_of_states * (1 + number_of_sensitivity_parameters) + # When n_sensitivity_params = 0: n_coeffs = n_states + # When n_sensitivity_params > 0: [state_0, d(state_0)/dp_0, d(state_0)/dp_1, ...] + n_coeffs = n_states * (1 + n_sensitivity_params) + y0_2d = np.zeros((1, n_coeffs), dtype=np.float64) + y0_2d[0, 0] = y0 # state value + + yp0_2d = np.zeros((1, n_coeffs), dtype=np.float64) + yp0_2d[0, 0] = -k * y0 # state derivative + + inputs_2d = np.array([[k]], dtype=np.float64) + + return { + "solver": solver, + "y0": y0_2d, + "yp0": yp0_2d, + "inputs": inputs_2d, + "model": exponential_decay_model, + } diff --git a/tests/test_functions.py b/tests/test_functions.py deleted file mode 100644 index 8b1433e..0000000 --- a/tests/test_functions.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Test module-level functions. - -This module tests the standalone functions provided by the idaklu module, -including observe, generate_function, and other utility functions. -""" - -import pytest -import numpy as np - - -class TestObserveFunction: - """Test the observe function.""" - - pytestmark = pytest.mark.unit - - def test_observe_exists(self, idaklu_module): - """Test that observe function exists.""" - assert hasattr(idaklu_module, "observe") - assert callable(idaklu_module.observe) - - def test_observe_with_empty_arrays(self, idaklu_module): - """Test observe with empty arrays raises TypeError.""" - with pytest.raises(TypeError): - idaklu_module.observe( - ts=np.array([]), - ys=np.array([]), - inputs=np.array([]), - funcs=[], - is_f_contiguous=True, - shape=[], - ) - - -class TestObserveHermiteInterpFunction: - """Test the observe_hermite_interp function.""" - - pytestmark = pytest.mark.unit - - def test_observe_hermite_interp_exists(self, idaklu_module): - """Test that observe_hermite_interp function exists.""" - assert hasattr(idaklu_module, "observe_hermite_interp") - assert callable(idaklu_module.observe_hermite_interp) - - def test_observe_hermite_interp_with_empty_arrays(self, idaklu_module): - """Test observe_hermite_interp with empty arrays raises TypeError.""" - with pytest.raises(TypeError): - idaklu_module.observe_hermite_interp( - t_interp=np.array([]), - ts=np.array([]), - ys=np.array([]), - yps=np.array([]), - inputs=np.array([]), - funcs=[], - shape=[], - ) - - -class TestGenerateFunction: - """Test the generate_function.""" - - pytestmark = pytest.mark.unit - - def test_generate_function_exists(self, idaklu_module): - """Test that generate_function exists.""" - assert hasattr(idaklu_module, "generate_function") - assert callable(idaklu_module.generate_function) - - def test_generate_function_with_empty_string(self, idaklu_module): - """Test generate_function with empty string raises RuntimeError.""" - with pytest.raises(RuntimeError): - idaklu_module.generate_function("") - - def test_generate_function_with_invalid_input(self, idaklu_module): - """Test generate_function with invalid CasADi expression.""" - with pytest.raises(RuntimeError): - idaklu_module.generate_function("invalid_casadi_expression") - - -class TestCreateCasadiSolverGroup: - """Test the create_casadi_solver_group function.""" - - pytestmark = pytest.mark.unit - - def test_create_casadi_solver_group_exists(self, idaklu_module): - """Test that create_casadi_solver_group function exists.""" - assert hasattr(idaklu_module, "create_casadi_solver_group") - assert callable(idaklu_module.create_casadi_solver_group) - - def test_create_casadi_solver_group_without_parameters(self, idaklu_module): - """Test that function fails appropriately without parameters.""" - with pytest.raises(TypeError): - idaklu_module.create_casadi_solver_group() - - -class TestCreateIdakluJax: - """Test the create_idaklu_jax function.""" - - pytestmark = pytest.mark.unit - - def test_create_idaklu_jax_exists(self, idaklu_module): - """Test that create_idaklu_jax function exists.""" - assert hasattr(idaklu_module, "create_idaklu_jax") - assert callable(idaklu_module.create_idaklu_jax) - - def test_create_idaklu_jax_callable(self, idaklu_module): - """Test that create_idaklu_jax can be called.""" - result = idaklu_module.create_idaklu_jax() - assert result is not None - - -class TestRegistrationsFunction: - """Test the registrations function.""" - - pytestmark = pytest.mark.unit - - def test_registrations_exists(self, idaklu_module): - """Test that registrations function exists.""" - assert hasattr(idaklu_module, "registrations") - assert callable(idaklu_module.registrations) - - def test_registrations_callable(self, idaklu_module): - """Test that registrations function can be called.""" - result = idaklu_module.registrations() - assert result is not None diff --git a/tests/test_integration.py b/tests/test_integration.py index 9036106..f96bc82 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,176 +1,201 @@ -"""Integration tests for pybammsolvers. +"""Integration tests for pybammsolvers pybindings functionality. -These tests verify interactions between different components -and may require more complex setup or be slower to run. +These tests verify that the C++ pybindings work correctly when called from Python +with realistic data and parameter configurations. """ -import pytest -import numpy as np -import gc - - -class TestVectorIntegration: - """Test integration between different vector types and operations.""" - - pytestmark = pytest.mark.integration - - def test_mixed_vector_operations(self, idaklu_module): - """Test mixed operations with different array types.""" - nd_vector = idaklu_module.VectorNdArray() - - arrays = [ - np.array([1.0, 2.0, 3.0]), - np.array([[1, 2], [3, 4]]), - np.ones((5,)), - np.zeros((2, 3)), - ] - - for arr in arrays: - nd_vector.append(arr.astype(np.float64)) - - assert len(nd_vector) == len(arrays) - - # Verify retrieval maintains shape and values - for i, original in enumerate(arrays): - retrieved = nd_vector[i] - np.testing.assert_array_equal(retrieved, original.astype(np.float64)) - - -class TestErrorRecovery: - """Test error handling and recovery in integration scenarios.""" - - pytestmark = pytest.mark.integration - - def test_partial_failure_recovery(self, idaklu_module): - """Test recovery from partial failures.""" - vector = idaklu_module.VectorNdArray() - - # Add valid arrays - valid_arrays = [np.array([1.0, 2.0]), np.array([3.0, 4.0])] - for arr in valid_arrays: - vector.append(arr) - - assert len(vector) == 2 +from __future__ import annotations - # Try to add invalid data - try: - vector.append("invalid") - except (TypeError, ValueError): - pass # Expected to fail - - # Verify valid data is still accessible - assert len(vector) == 2 - np.testing.assert_array_equal(vector[0], valid_arrays[0]) - np.testing.assert_array_equal(vector[1], valid_arrays[1]) - - # Should be able to continue adding valid data - vector.append(np.array([5.0, 6.0])) - assert len(vector) == 3 - - def test_large_data_handling(self, idaklu_module): - """Test handling of moderately large datasets.""" - vector = idaklu_module.VectorNdArray() - - large_arrays = [] - for _i in range(10): - arr = np.random.rand(100, 50).astype(np.float64) - large_arrays.append(arr) - vector.append(arr) - - assert len(vector) == 10 - - # Verify all arrays are accessible and correct - for i, original in enumerate(large_arrays): - retrieved = vector[i] - assert retrieved.shape == original.shape - np.testing.assert_array_equal(retrieved, original) - - -class TestMemoryManagement: - """Test memory management in integration scenarios.""" - - pytestmark = pytest.mark.integration - - def test_memory_cleanup_basic(self, idaklu_module): - """Test that memory is properly cleaned up.""" - vectors = [] - - # Create many vector objects - for _ in range(100): - vector = idaklu_module.VectorNdArray() - for _ in range(10): - arr = np.random.rand(100).astype(np.float64) - vector.append(arr) - vectors.append(vector) - - # Clear references - vectors.clear() - gc.collect() - - # If we get here without crashing, memory management is working - - def test_solution_vector_memory_cleanup(self, idaklu_module): - """Test memory cleanup for solution vectors.""" - vectors = [] +import numpy as np +import pytest - # Create many solution vector objects - for _ in range(100): - vector = idaklu_module.VectorSolution() - vectors.append(vector) +import pybammsolvers - # Clear references - vectors.clear() - gc.collect() - # If we get here without crashing, memory management is working +def is_monotonic_increasing(arr): + return np.all(np.diff(arr) >= 0) -class TestStressConditions: - """Test behavior under stress conditions.""" +class TestExponentialDecaySolver: + """Integration tests using exponential decay model to test solver and Solution.""" pytestmark = pytest.mark.integration - def test_concurrent_access_simulation(self, idaklu_module): - """Simulate concurrent access patterns (single-threaded).""" - vector = idaklu_module.VectorNdArray() - - # Add initial data - for i in range(100): - arr = np.array([i, i + 1, i + 2], dtype=np.float64) - vector.append(arr) - - # Simulate mixed read/write operations - for _ in range(1000): - # Random read - idx = np.random.randint(0, len(vector)) - _ = vector[idx] - - # Occasional write - if np.random.rand() < 0.1: # 10% chance - new_arr = np.random.rand(3).astype(np.float64) - vector.append(new_arr) - - # Verify integrity - assert len(vector) >= 100 - - def test_boundary_stress(self, idaklu_module): - """Test boundary conditions under stress.""" - vector = idaklu_module.VectorNdArray() - - # Mix of extreme values - extreme_arrays = [ - np.array([1e-100], dtype=np.float64), - np.array([1e100], dtype=np.float64), - np.array([np.inf], dtype=np.float64), - np.array([0.0], dtype=np.float64), - np.array([np.finfo(np.float64).tiny], dtype=np.float64), - ] - - for arr in extreme_arrays: - vector.append(arr) - - # Repeatedly access these - for _ in range(100): - for i in range(len(vector)): - retrieved = vector[i] - assert retrieved is not None + def test_exponential_decay_solve(self, exponential_decay_solver): + """ + Verify the solver can solve exponential decay and return a Solution object. + + Tests that the complete solver pipeline works: setup, solve, and return results. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + + # Solve the system + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + + # Get the first solution object + sol = solution[0] + assert isinstance(sol, pybammsolvers.idaklu.solution) + assert isinstance(sol.y, np.ndarray) + assert isinstance(sol.y[0], np.floating) + + def test_solution_has_time_array(self, exponential_decay_solver): + """ + Verify Solution object contains time array with correct values. + + Tests that the Solution.t attribute contains the evaluation times. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + + # Check time array exists and has correct properties + assert hasattr(sol, "t") + assert isinstance(sol.t, np.ndarray) + assert sol.t.shape[0] == len(t_eval) + + # Verify times are increasing + assert is_monotonic_increasing(sol.t) + + # Verify times are in expected range + assert sol.t[0] >= t_eval[0] + assert sol.t[-1] <= t_eval[-1] + + def test_solution_has_derivative_array(self, exponential_decay_solver): + """ + Verify Solution object contains state derivative array. + + Tests that the Solution.yp attribute contains derivatives at each time point. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + + # Check derivative array exists + assert hasattr(sol, "yp") + assert isinstance(sol.yp, np.ndarray) + + def test_solution_has_termination_flag(self, exponential_decay_solver): + """ + Verify Solution object contains termination flag. + + Tests that the Solution.flag attribute indicates successful completion. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + + # Check flag exists + assert hasattr(sol, "flag") + assert isinstance(sol.flag, int) + + # Flag should indicate success + # IDA_SUCCESS=0, IDA_TSTOP_RETURN=1, IDA_ROOT_RETURN=2 are all success codes + assert sol.flag in [0, 1, 2], f"Solver failed with flag {sol.flag}" + + def test_solution_accuracy_exponential_decay(self, exponential_decay_solver): + """ + Verify Solution matches exact solution for exponential decay. + + Tests numerical accuracy by comparing solver output to known analytical solution. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + exact_solution = solver_data["model"]["exact_solution"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + + # Compare numerical solution to exact solution + y_numerical = sol.y + y_exact = exact_solution(sol.t) + np.testing.assert_allclose(y_numerical, y_exact, rtol=1e-5, atol=1e-8) + + def test_solution_initial_conditions(self, exponential_decay_solver): + """ + Verify Solution respects initial conditions. + + Tests that the first point in the solution matches the provided initial conditions. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + model_y0 = solver_data["model"]["y0"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + + # First state value should match initial condition + assert sol.t[0] == pytest.approx(t_eval[0]) + np.testing.assert_allclose(sol.y[0], model_y0, rtol=1e-10) + + def test_solution_output_variables(self, exponential_decay_solver): + """ + Verify Solution contains output variable evaluations. + + Tests that output variables (y_term) are computed correctly. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + exact_solution = solver_data["model"]["exact_solution"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + y_exact = exact_solution(sol.t) + + # For our model, the output variable is simply the final state slice + np.testing.assert_allclose(sol.y_term, y_exact[-1], rtol=1e-5) + + def test_solution_dimensions_consistency(self, exponential_decay_solver): + """ + Verify Solution arrays have consistent dimensions. + + Tests that t, y, and yp arrays have compatible shapes. + """ + solver_data = exponential_decay_solver + solver = solver_data["solver"] + y0 = solver_data["y0"] + yp0 = solver_data["yp0"] + inputs = solver_data["inputs"] + t_eval = solver_data["model"]["t_eval"] + + solution = solver.solve(t_eval, t_eval, y0, yp0, inputs) + sol = solution[0] + + # All arrays should have compatible dimensions + n_times = len(sol.t) + assert len(sol.y) == n_times + assert len(sol.yp) == 0 # hermite_interpolation == False diff --git a/tests/test_module.py b/tests/test_module.py index 5625dd1..f6343f2 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -1,184 +1,47 @@ -"""Test module imports and basic structure. +"""Unit tests for pybammsolvers core functionality.""" -This module consolidates all tests related to module imports, -class/function availability, and basic documentation. -""" +from __future__ import annotations import pytest -import inspect -import io -import contextlib -class TestImport: - """Test basic module import functionality.""" +class TestPackageStructure: + """Test pybammsolvers package structure and metadata.""" pytestmark = pytest.mark.unit - def test_pybammsolvers_import(self): - """Test that pybammsolvers can be imported.""" - import pybammsolvers - - assert pybammsolvers is not None - - def test_idaklu_module_import(self, idaklu_module): - """Test that idaklu module is accessible.""" - assert idaklu_module is not None - - def test_version_import(self): - """Test that version can be imported.""" - from pybammsolvers.version import __version__ - - assert __version__ is not None - assert isinstance(__version__, str) - + def test_package_import(self): + """ + Verify pybammsolvers package can be imported and exposes the idaklu module. -class TestClasses: - """Test that all expected classes are available.""" + The idaklu module is the core C++ extension that provides SUNDIALS IDA solver + bindings with KLU sparse linear solver support. + """ + import pybammsolvers - pytestmark = pytest.mark.unit + assert hasattr(pybammsolvers, "idaklu") + assert hasattr(pybammsolvers, "__version__") + assert isinstance(pybammsolvers.__version__, str) + assert len(pybammsolvers.__version__) > 0 - def test_solver_group_class_exists(self, idaklu_module): - """Test that IDAKLUSolverGroup class exists.""" - assert hasattr(idaklu_module, "IDAKLUSolverGroup") - assert callable(idaklu_module.IDAKLUSolverGroup) + def test_idaklu_module_attributes(self, idaklu_module): + """ + Verify idaklu module exposes the expected classes and functions. - def test_idaklu_jax_class_exists(self, idaklu_module): - """Test that IdakluJax class exists.""" - assert hasattr(idaklu_module, "IdakluJax") - assert callable(idaklu_module.IdakluJax) - - def test_solution_class_exists(self, idaklu_module): - """Test that solution class exists.""" + The module should provide Solution, VectorNdArray, VectorSolution for managing + solver results, and IdakluJax for JAX integration. + """ + # Core solution classes assert hasattr(idaklu_module, "solution") - assert callable(idaklu_module.solution) - - def test_vector_classes_exist(self, idaklu_module): - """Test that vector classes exist.""" assert hasattr(idaklu_module, "VectorNdArray") - assert hasattr(idaklu_module, "VectorRealtypeNdArray") assert hasattr(idaklu_module, "VectorSolution") - def test_function_class_exists(self, idaklu_module): - """Test that Function class exists (CasADi).""" - assert hasattr(idaklu_module, "Function") - - -class TestFunctions: - """Test that all expected functions are available.""" - - pytestmark = pytest.mark.unit - - @pytest.mark.parametrize( - "func_name", - [ - "create_casadi_solver_group", - "observe", - "observe_hermite_interp", - "generate_function", - "create_idaklu_jax", - "registrations", - ], - ) - def test_function_exists_and_callable(self, idaklu_module, func_name): - """Test that critical functions exist and are callable.""" - assert hasattr(idaklu_module, func_name), f"Missing function: {func_name}" - func = getattr(idaklu_module, func_name) - assert callable(func), f"Function {func_name} is not callable" - - -class TestDocumentation: - """Test module and class documentation.""" - - pytestmark = pytest.mark.unit - - def test_module_has_docstring(self, idaklu_module): - """Test that the idaklu module has documentation.""" - assert hasattr(idaklu_module, "__doc__") - assert idaklu_module.__doc__ is not None - assert len(idaklu_module.__doc__.strip()) > 0 - - @pytest.mark.parametrize( - "class_name", - ["IDAKLUSolverGroup", "IdakluJax", "solution"], - ) - def test_class_has_docstring(self, idaklu_module, class_name): - """Test that main classes have docstrings.""" - cls = getattr(idaklu_module, class_name) - assert hasattr(cls, "__doc__") - - def test_help_functionality(self, idaklu_module): - """Test that help() works on main components.""" - components = [ - idaklu_module, - idaklu_module.solution, - idaklu_module.VectorNdArray, - ] - - for component in components: - f = io.StringIO() - with contextlib.redirect_stdout(f): - help(component) - help_text = f.getvalue() - assert len(help_text) > 0 - - @pytest.mark.parametrize( - "func_name", - [ - "create_casadi_solver_group", - "observe", - "observe_hermite_interp", - "generate_function", - ], - ) - def test_function_has_signature(self, idaklu_module, func_name): - """Test that functions have reasonable signatures.""" - func = getattr(idaklu_module, func_name) - - # Should be callable - assert callable(func) - - # Try to get signature (might fail for C++ functions) - try: - sig = inspect.signature(func) - assert sig is not None - except (ValueError, TypeError): - # C++ functions might not have inspectable signatures - pytest.skip(f"{func_name} signature not inspectable (C++ binding)") - - -class TestBasicFunctionality: - """Test basic functionality that doesn't require complex setup.""" - - pytestmark = pytest.mark.unit - - def test_registrations_function(self, idaklu_module): - """Test that registrations function can be called.""" - result = idaklu_module.registrations() - assert result is not None - - def test_create_idaklu_jax_function(self, idaklu_module): - """Test that create_idaklu_jax function can be called.""" - result = idaklu_module.create_idaklu_jax() - assert result is not None - - def test_solver_group_creation_without_parameters(self, idaklu_module): - """Test that solver group creation fails appropriately without parameters.""" - with pytest.raises(TypeError): - idaklu_module.create_casadi_solver_group() - - -class TestErrorHandling: - """Test error handling for invalid inputs.""" - - pytestmark = pytest.mark.unit - - def test_generate_function_with_empty_string(self, idaklu_module): - """Test generate_function with empty string.""" - with pytest.raises(RuntimeError): - idaklu_module.generate_function("") + # JAX integration + assert hasattr(idaklu_module, "IdakluJax") + assert hasattr(idaklu_module, "create_idaklu_jax") - def test_generate_function_with_invalid_expression(self, idaklu_module): - """Test generate_function with invalid CasADi expression.""" - with pytest.raises(RuntimeError): - idaklu_module.generate_function("invalid_casadi_expression") + # Verify they're callable/instantiable + assert callable(idaklu_module.solution) + assert callable(idaklu_module.VectorNdArray) + assert callable(idaklu_module.VectorSolution) + assert callable(idaklu_module.create_idaklu_jax) From 1e78371492e075f6d31cb16c764317d9e326cca3 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Wed, 15 Oct 2025 08:53:56 +0100 Subject: [PATCH 14/20] precommit additions --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index f96bc82..b5b31c3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -198,4 +198,4 @@ def test_solution_dimensions_consistency(self, exponential_decay_solver): # All arrays should have compatible dimensions n_times = len(sol.t) assert len(sol.y) == n_times - assert len(sol.yp) == 0 # hermite_interpolation == False + assert len(sol.yp) == 0 # hermite_interpolation == False From 728ec40d2d8bc0843f17c819ae46f506a0fe2ff0 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Thu, 16 Oct 2025 13:45:29 +0100 Subject: [PATCH 15/20] Suggestions from review Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> --- tests/conftest.py | 13 +------------ tests/test_integration.py | 2 +- tests/test_module.py | 25 +++++++++++++++++++++++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6b86d9c..dc6eb4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,14 +6,6 @@ import os import numpy as np -try: - import casadi - - CASADI_AVAILABLE = True -except ImportError: - CASADI_AVAILABLE = False - - def pytest_configure(config): """Configure pytest with custom markers.""" config.addinivalue_line("markers", "integration: marks tests as integration tests") @@ -56,8 +48,6 @@ def exponential_decay_model(): - 't_eval': evaluation times - 'exact_solution': function to compute exact solution at any time """ - if not CASADI_AVAILABLE: - pytest.skip("CasADi not available") # Model parameters k = 0.5 # decay constant @@ -84,8 +74,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): Sets up a complete IDAKLU solver instance with the exponential decay ODE system ready to solve. """ - if not CASADI_AVAILABLE: - pytest.skip("CasADi not available") + casadi = pytest.importorskip("casadi") # Model parameters k = exponential_decay_model["k"] diff --git a/tests/test_integration.py b/tests/test_integration.py index b5b31c3..aabc686 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,6 +1,6 @@ """Integration tests for pybammsolvers pybindings functionality. -These tests verify that the C++ pybindings work correctly when called from Python +These tests verify that the C++ bindings work correctly when called from Python with realistic data and parameter configurations. """ diff --git a/tests/test_module.py b/tests/test_module.py index f6343f2..01559c4 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -18,8 +18,13 @@ def test_package_import(self): bindings with KLU sparse linear solver support. """ import pybammsolvers - assert hasattr(pybammsolvers, "idaklu") + + # Ensure the idaklu C++ extension module is importable + import pybammsolvers.idaklu + assert pybammsolvers.idaklu is not None + + # Versioning assert hasattr(pybammsolvers, "__version__") assert isinstance(pybammsolvers.__version__, str) assert len(pybammsolvers.__version__) > 0 @@ -31,17 +36,33 @@ def test_idaklu_module_attributes(self, idaklu_module): The module should provide Solution, VectorNdArray, VectorSolution for managing solver results, and IdakluJax for JAX integration. """ + # Core solution classes assert hasattr(idaklu_module, "solution") assert hasattr(idaklu_module, "VectorNdArray") assert hasattr(idaklu_module, "VectorSolution") + assert hasattr(idaklu_module, "VectorRealtypeNdArray") + + # Solver classes + assert hasattr(idaklu_module, "IDAKLUSolverGroup") + + # Core functions + assert hasattr(idaklu_module, "create_casadi_solver_group") + assert hasattr(idaklu_module, "generate_function") + assert hasattr(idaklu_module, "observe") + assert hasattr(idaklu_module, "observe_hermite_interp") # JAX integration assert hasattr(idaklu_module, "IdakluJax") assert hasattr(idaklu_module, "create_idaklu_jax") + assert hasattr(idaklu_module, "registrations") - # Verify they're callable/instantiable + # Verify key classes and functions are callable/instantiable assert callable(idaklu_module.solution) assert callable(idaklu_module.VectorNdArray) assert callable(idaklu_module.VectorSolution) + assert callable(idaklu_module.VectorRealtypeNdArray) + assert callable(idaklu_module.IDAKLUSolverGroup) + assert callable(idaklu_module.create_casadi_solver_group) + assert callable(idaklu_module.generate_function) assert callable(idaklu_module.create_idaklu_jax) From dc5427ff198ef7695bc030d64833a073cc08307a Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Thu, 16 Oct 2025 13:47:18 +0100 Subject: [PATCH 16/20] precommit additions --- tests/conftest.py | 1 + tests/test_module.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index dc6eb4f..56acc01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import os import numpy as np + def pytest_configure(config): """Configure pytest with custom markers.""" config.addinivalue_line("markers", "integration: marks tests as integration tests") diff --git a/tests/test_module.py b/tests/test_module.py index 01559c4..7bb7d7d 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -18,10 +18,12 @@ def test_package_import(self): bindings with KLU sparse linear solver support. """ import pybammsolvers + assert hasattr(pybammsolvers, "idaklu") # Ensure the idaklu C++ extension module is importable import pybammsolvers.idaklu + assert pybammsolvers.idaklu is not None # Versioning @@ -45,7 +47,7 @@ def test_idaklu_module_attributes(self, idaklu_module): # Solver classes assert hasattr(idaklu_module, "IDAKLUSolverGroup") - + # Core functions assert hasattr(idaklu_module, "create_casadi_solver_group") assert hasattr(idaklu_module, "generate_function") From a204965cbac470a122f053be91c4fd19e5b08a9b Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Thu, 16 Oct 2025 14:15:06 +0100 Subject: [PATCH 17/20] fix: avoid single-element vector operations for MacOS-intel --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 56acc01..b280e7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,7 +92,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): p_sym = casadi.MX.sym("p", n_inputs) # RHS function: For ODE dy/dt = -k*y - rhs = casadi.vertcat(-p_sym[0] * y_sym[0]) + rhs = -p_sym * y_sym # Create RHS function: t, y, inputs rhs_alg = casadi.Function("rhs_alg", [t_sym, y_sym, p_sym], [rhs]) @@ -102,7 +102,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): # mass_matrix = 1 (identity for ODE) # So: jac_times_cjmass = -k - cj * 1 = -k - cj cj_sym = casadi.MX.sym("cj") - jac_result = casadi.vertcat(-p_sym[0] - cj_sym) + jac_result = casadi.vertcat(-p_sym - cj_sym) jac_times_cjmass = casadi.Function( "jac_times_cjmass", [t_sym, y_sym, p_sym, cj_sym], [jac_result] ) @@ -120,7 +120,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): # Jacobian action (for matrix-free methods): d(rhs)/dy @ v # For rhs = -k*y: d(rhs)/dy = -k, so jac_action = -k * v - jac_action_result = casadi.vertcat(-p_sym[0] * v_sym[0]) + jac_action_result = -p_sym * v_sym jac_action = casadi.Function( "jac_action", [t_sym, y_sym, p_sym, v_sym], [jac_action_result] ) @@ -149,7 +149,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): rtol = 1e-8 # Output variables (just return the state itself as a vector) - var_fcn = casadi.Function("var", [t_sym, y_sym, p_sym], [casadi.vertcat(y_sym[0])]) + var_fcn = casadi.Function("var", [t_sym, y_sym, p_sym], [y_sym]) var_fcns = [idaklu_module.generate_function(var_fcn.serialize())] # Sensitivities of output wrt states and params From accd07f542cc7cedef1a59d553fe2d2c10d09996 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Fri, 17 Oct 2025 13:21:49 +0100 Subject: [PATCH 18/20] another test: separate negate from casadi symbol --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b280e7f..a815a50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,7 +92,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): p_sym = casadi.MX.sym("p", n_inputs) # RHS function: For ODE dy/dt = -k*y - rhs = -p_sym * y_sym + rhs = -1.0 * p_sym * y_sym # Create RHS function: t, y, inputs rhs_alg = casadi.Function("rhs_alg", [t_sym, y_sym, p_sym], [rhs]) @@ -120,7 +120,7 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): # Jacobian action (for matrix-free methods): d(rhs)/dy @ v # For rhs = -k*y: d(rhs)/dy = -k, so jac_action = -k * v - jac_action_result = -p_sym * v_sym + jac_action_result = -1.0 * p_sym * v_sym jac_action = casadi.Function( "jac_action", [t_sym, y_sym, p_sym, v_sym], [jac_action_result] ) From c5736758fa0a0093a7f773e7a98467db8ac7be71 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 20 Oct 2025 11:57:41 +0100 Subject: [PATCH 19/20] infra: skip macosx_x86_x64 tests for build_wheels.yml --- .github/workflows/build_wheels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 32e7181..41d1d88 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -177,6 +177,8 @@ jobs: delocate-listdeps {wheel} && delocate-wheel -v -w {dest_dir} {wheel} --require-target-macos-version 11.1 for file in {dest_dir}/*.whl; do mv "$file" "${file//macosx_11_1/macosx_11_0}"; done fi + # Skip tests for MacOS-Intel due to upstream bug + CIBW_TEST_SKIP: "cp*-macosx_x86_64" CIBW_TEST_EXTRAS: "dev" CIBW_TEST_COMMAND: | set -e -x From c137109aaa82c8c9d31745ac88c127b29eb01f04 Mon Sep 17 00:00:00 2001 From: bradyplanden Date: Mon, 20 Oct 2025 14:10:52 +0100 Subject: [PATCH 20/20] infra: test level skip instead of workflow level --- .github/workflows/build_wheels.yml | 2 -- tests/conftest.py | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 41d1d88..32e7181 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -177,8 +177,6 @@ jobs: delocate-listdeps {wheel} && delocate-wheel -v -w {dest_dir} {wheel} --require-target-macos-version 11.1 for file in {dest_dir}/*.whl; do mv "$file" "${file//macosx_11_1/macosx_11_0}"; done fi - # Skip tests for MacOS-Intel due to upstream bug - CIBW_TEST_SKIP: "cp*-macosx_x86_64" CIBW_TEST_EXTRAS: "dev" CIBW_TEST_COMMAND: | set -e -x diff --git a/tests/conftest.py b/tests/conftest.py index a815a50..ab02c49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ import pytest import os import numpy as np +import sys +import platform def pytest_configure(config): @@ -75,6 +77,10 @@ def exponential_decay_solver(idaklu_module, exponential_decay_model): Sets up a complete IDAKLU solver instance with the exponential decay ODE system ready to solve. """ + # Skip tests using this fixture on macOS Intel + if sys.platform == "darwin" and platform.machine() != "arm64": + pytest.skip("Skipping exponential_decay_solver tests on macOS Intel") + casadi = pytest.importorskip("casadi") # Model parameters