From c687e2b27ea5ae5cbb9876b01bff9b348a072bd9 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 5 Mar 2026 22:47:36 +0100 Subject: [PATCH 1/8] Exclude libomp from delocate and add macOS rpaths Prevent delocate from bundling libomp.dylib by adding --exclude libomp.dylib to the repair-wheel-command in pyproject.toml. Update simcoon-python-builder/CMakeLists.txt to set INSTALL_RPATH for the _core target on macOS to @loader_path, @loader_path/../../.., /usr/local/lib and /opt/homebrew/opt/libomp/lib so the wheel can find libsimcoon and system/conda/Homebrew libomp at runtime (keep BUILD_WITH_INSTALL_RPATH ON). This avoids shipping libomp in wheels and improves compatibility across CI, conda, and Homebrew environments. --- pyproject.toml | 2 +- simcoon-python-builder/CMakeLists.txt | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0ee9a6a7..9a1abcc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,7 +186,7 @@ before-all = [ archs = ["arm64"] # delocate bundles shared libraries -repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}" +repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v --exclude libomp.dylib {wheel}" # Add macOS-specific env without replacing global environment (ARMADILLO_VERSION, etc.) [[tool.cibuildwheel.overrides]] diff --git a/simcoon-python-builder/CMakeLists.txt b/simcoon-python-builder/CMakeLists.txt index a75cf0f9..9d211e64 100755 --- a/simcoon-python-builder/CMakeLists.txt +++ b/simcoon-python-builder/CMakeLists.txt @@ -74,8 +74,12 @@ if(CONDA_BUILD) else() # Wheel: libsimcoon is in same directory as _core if(APPLE) + # @loader_path → find libsimcoon.dylib next to _core.so + # @loader_path/../../.. → $CONDA_PREFIX/lib (conda's libomp.dylib) + # /usr/local/lib → CI / manual installs + # /opt/homebrew/opt/libomp/lib → Homebrew on Apple Silicon set_target_properties(_core PROPERTIES - INSTALL_RPATH "@loader_path" + INSTALL_RPATH "@loader_path;@loader_path/../../..;/usr/local/lib;/opt/homebrew/opt/libomp/lib" BUILD_WITH_INSTALL_RPATH ON ) elseif(UNIX) From 87e644831687fcb3257d2586fb507f6d84197780 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Tue, 10 Mar 2026 16:27:17 +0100 Subject: [PATCH 2/8] Add Fedoo integration CI workflow and tests Introduce a GitHub Actions workflow (fedoo-integration.yml) to run integration tests across multiple fedoo versions (0.7.0, 0.6.1, 0.6.0) and OS runners (Linux, macOS). The workflow installs system deps, builds and installs the simcoon wheel, installs fedoo, and runs tests; it also includes a macOS check to ensure libomp is not bundled. Add comprehensive pytest integration file tests/test_fedoo_integration.py exercising EPICP and Neo-Hookean behaviors, non-linear geometry, OpenMP-parallelized umat calls (including a threads parametrized test), and tangent/plasticity checks. --- .github/workflows/fedoo-integration.yml | 63 +++++++++ tests/test_fedoo_integration.py | 173 ++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 .github/workflows/fedoo-integration.yml create mode 100644 tests/test_fedoo_integration.py diff --git a/.github/workflows/fedoo-integration.yml b/.github/workflows/fedoo-integration.yml new file mode 100644 index 00000000..1dc8f726 --- /dev/null +++ b/.github/workflows/fedoo-integration.yml @@ -0,0 +1,63 @@ +name: Fedoo Integration + +on: + workflow_dispatch: + push: + branches: [master, fix/openMP] + pull_request: + branches: [master] + +permissions: + contents: read + actions: read + +jobs: + fedoo-integration: + strategy: + fail-fast: false + matrix: + fedoo-version: ["0.7.0", "0.6.1", "0.6.0"] + os: + - name: Linux + runner: ubuntu-latest + sys-deps: | + sudo apt-get update + sudo apt-get install -y libarmadillo-dev ninja-build + - name: macOS + runner: macos-latest + sys-deps: brew install armadillo ninja libomp + + runs-on: ${{ matrix.os.runner }} + name: ${{ matrix.os.name }} / fedoo ${{ matrix.fedoo-version }} + + steps: + - uses: actions/checkout@v6 + + - name: Install system dependencies + run: ${{ matrix.os.sys-deps }} + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Build simcoon wheel and install with fedoo + run: | + uv venv + uv pip install scikit-build-core pybind11 numpy + uv pip install . --no-build-isolation + uv pip install "fedoo==${{ matrix.fedoo-version }}" pytest + + - name: Verify OpenMP is not duplicated (macOS) + if: runner.os == 'macOS' + run: | + # Check that _core.so links to system libomp via RPATH, not a bundled one + uv run python -c " + import simcoon._core as c, os, pathlib + core = pathlib.Path(c.__file__) + pkg = core.parent + bundled = list(pkg.glob('*libomp*')) + assert not bundled, f'libomp should NOT be bundled in the wheel: {bundled}' + print('OK: no bundled libomp found in', pkg) + " + + - name: Run fedoo integration tests + run: uv run pytest tests/test_fedoo_integration.py -v diff --git a/tests/test_fedoo_integration.py b/tests/test_fedoo_integration.py new file mode 100644 index 00000000..934f8649 --- /dev/null +++ b/tests/test_fedoo_integration.py @@ -0,0 +1,173 @@ +"""Integration tests for fedoo + simcoon. + +Tests both non-linear umat (OpenMP) and non-linear geometry +(tangent modulus transfer via deformation gradient). +""" + +import numpy as np +import pytest + +import fedoo +import simcoon +from simcoon import simmit as sim + + +@pytest.fixture(autouse=True) +def _reset_fedoo(): + """Reset fedoo global state between tests.""" + fedoo.ModelingSpace("3D") + yield + + +def _make_cube(name, nx=2): + """Create a unit cube mesh with standard Dirichlet BCs on min faces.""" + return fedoo.mesh.box_mesh( + nx=nx, ny=nx, nz=nx, + x_min=0, x_max=1, y_min=0, y_max=1, z_min=0, z_max=1, + elm_type="hex8", name=name, + ) + + +def _apply_compression_bc(pb, mesh, disp_z): + """Fix min faces, prescribe DispZ on top face.""" + pb.bc.add("Dirichlet", mesh.find_nodes("X", mesh.bounding_box.xmin), "DispX", 0) + pb.bc.add("Dirichlet", mesh.find_nodes("Y", mesh.bounding_box.ymin), "DispY", 0) + pb.bc.add("Dirichlet", mesh.find_nodes("Z", mesh.bounding_box.zmin), "DispZ", 0) + pb.bc.add("Dirichlet", mesh.find_nodes("Z", mesh.bounding_box.zmax), "DispZ", disp_z) + + +# ── Test 1: Non-linear UMAT (EPICP) ─ small strain ─ exercises OpenMP ── + +def test_epicp_small_strain(): + """Elastoplastic EPICP umat under small-strain compression. + + This exercises the OpenMP-parallelised umat call (multi-point + stress integration) with a non-linear material (plasticity). + """ + mesh = _make_cube("epicp_ss") + props = np.array([200e3, 0.3, 0.0, 300.0, 1000.0, 0.5]) + mat = fedoo.constitutivelaw.Simcoon("EPICP", props, name="epicp_ss_mat") + + wf = fedoo.weakform.StressEquilibrium(mat, name="wf_epicp_ss") + asm = fedoo.Assembly.create(wf, mesh, elm_type="hex8", name="asm_epicp_ss") + + pb = fedoo.problem.NonLinear(asm, name="pb_epicp_ss") + _apply_compression_bc(pb, mesh, disp_z=0.01) + + pb.nlsolve(dt=0.5, tmax=1.0, update_dt=True, print_info=0) + + disp = pb.get_disp("DispZ") + assert np.max(np.abs(disp)) == pytest.approx(0.01, abs=1e-8) + + # Verify plastic deformation occurred (statev[1] = accumulated plastic strain p) + p = asm.sv["Statev"][1] + assert np.max(p) > 0, "Expected plastic deformation" + + +# ── Test 2: Non-linear geometry (Neo-Hookean) ─ tangent modulus transfer ── + +def test_neohookean_nlgeom(): + """Neo-Hookean hyperelastic under large-strain compression. + + This exercises non-linear geometry (nlgeom=True) which involves + deformation gradient transfer and tangent modulus computation. + """ + mesh = _make_cube("neohc_nl") + props = np.array([80e3, 400e3]) # mu, kappa + mat = fedoo.constitutivelaw.Simcoon("NEOHC", props, name="neohc_nl_mat") + + wf = fedoo.weakform.StressEquilibrium(mat, name="wf_neohc_nl", nlgeom=True) + asm = fedoo.Assembly.create(wf, mesh, elm_type="hex8", name="asm_neohc_nl") + + pb = fedoo.problem.NonLinear(asm, nlgeom=True, name="pb_neohc_nl") + _apply_compression_bc(pb, mesh, disp_z=0.1) + + pb.nlsolve(dt=0.5, tmax=1.0, update_dt=True, print_info=0) + + disp = pb.get_disp("DispZ") + assert np.max(np.abs(disp)) == pytest.approx(0.1, abs=1e-8) + + # Verify deformation gradient was tracked + F = asm.sv["F"] + assert F is not None + assert not np.allclose(F, np.eye(3).reshape(3, 3, 1)), \ + "Deformation gradient should differ from identity" + + +# ── Test 3: EPICP + nlgeom ─ OpenMP umat + tangent modulus + large strain ── + +def test_epicp_nlgeom(): + """Elastoplastic EPICP under large-strain compression. + + This is the most demanding test: non-linear material (OpenMP umat) + combined with non-linear geometry (deformation gradient and tangent + modulus transfer between fedoo and simcoon). + """ + mesh = _make_cube("epicp_nl") + props = np.array([200e3, 0.3, 0.0, 300.0, 1000.0, 0.5]) + mat = fedoo.constitutivelaw.Simcoon("EPICP", props, name="epicp_nl_mat") + + wf = fedoo.weakform.StressEquilibrium(mat, name="wf_epicp_nl", nlgeom=True) + asm = fedoo.Assembly.create(wf, mesh, elm_type="hex8", name="asm_epicp_nl") + + pb = fedoo.problem.NonLinear(asm, nlgeom=True, name="pb_epicp_nl") + _apply_compression_bc(pb, mesh, disp_z=0.05) + + pb.nlsolve(dt=0.25, tmax=1.0, update_dt=True, print_info=0) + + disp = pb.get_disp("DispZ") + assert np.max(np.abs(disp)) == pytest.approx(0.05, abs=1e-8) + + # Verify plastic deformation + p = asm.sv["Statev"][1] + assert np.max(p) > 0, "Expected plastic deformation" + + # Verify deformation gradient was tracked + F = asm.sv["F"] + assert not np.allclose(F, np.eye(3).reshape(3, 3, 1)) + + # Verify tangent matrix is populated and symmetric + Lt = asm.sv["TangentMatrix"] + assert Lt is not None + sym_err = np.abs(Lt - Lt.transpose((1, 0, 2))).max() + assert sym_err < 1e-3, f"Tangent matrix not symmetric (err={sym_err})" + + +# ── Test 4: Direct sim.umat call with explicit n_threads ── + +@pytest.mark.parametrize("n_threads", [1, 2, 4]) +def test_umat_openmp_threads(n_threads): + """Call sim.umat directly with explicit n_threads to verify OpenMP works. + + With multiple Gauss points the umat loop is OpenMP-parallelised. + Results must be identical regardless of thread count. + """ + n_gauss = 64 # simulate 64 Gauss points (like 8 hex8 elements) + props = np.asfortranarray( + np.tile([200e3, 0.3, 0.0, 300.0, 1000.0, 0.5], (n_gauss, 1)).T + ) + + etot = np.zeros((6, n_gauss), order="F") + # Apply a uniaxial strain increment that exceeds yield + Detot = np.zeros((6, n_gauss), order="F") + Detot[2, :] = 5e-3 # eps_zz increment + + sigma = np.zeros((6, n_gauss), order="F") + statev = np.zeros((8, n_gauss), order="F") + Wm = np.zeros((4, n_gauss), order="F") + DR = np.empty((3, 3, n_gauss), order="F") + DR[...] = np.eye(3).reshape(3, 3, 1) + + stress, sv, wm, Lt = sim.umat( + "EPICP", etot, Detot, + np.array([]), np.array([]), + sigma, DR, props, statev, + 0.0, 1.0, Wm, n_threads=n_threads, + ) + + # All Gauss points have identical input → identical output + assert np.allclose(stress[:, 0:1], stress, atol=1e-6), \ + f"Stress not uniform across Gauss points with n_threads={n_threads}" + + # Should have yielded (sigma_Y = 300 MPa, E*eps = 200e3*5e-3 = 1000 MPa) + assert sv[1, 0] > 0, "Expected plastic strain with n_threads={n_threads}" From 0c31120853c085521c0705eb0bf49e516d78f4e8 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Tue, 10 Mar 2026 17:23:29 +0100 Subject: [PATCH 3/8] Add batch rotation support and CI fedoo tests Add batch (Gauss-point) support to Rotation: introduce _is_batch and _voigt_* helpers, disallow converting batches to single _CppRotation, and vectorize mechanics methods (apply_stress/strain/stiffness/compliance/tensor and voigt rotation accessors) using NumPy einsum. Add unit tests covering batch operations and invariants in test_rotation.py. Integrate fedoo integration test steps into existing GitHub workflows (build.yml and ci.yml) for non-Windows runners and remove the dedicated fedoo-integration.yml workflow. Minor cleanups and docstring clarifications in tests/test_fedoo_integration.py. --- .github/workflows/build.yml | 6 + .github/workflows/ci.yml | 7 + .github/workflows/fedoo-integration.yml | 63 -------- python-setup/simcoon/rotation.py | 149 +++++++++++++----- .../test/test_core/test_rotation.py | 97 ++++++++++++ tests/test_fedoo_integration.py | 59 +------ 6 files changed, 231 insertions(+), 150 deletions(-) delete mode 100644 .github/workflows/fedoo-integration.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b13f941..2444fb15 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,3 +72,9 @@ jobs: - name: Run Python tests run: uv run pytest + + - name: Install fedoo and run integration tests (Unix) + if: runner.os != 'Windows' + run: | + uv pip install "fedoo==0.8.0" + uv run pytest tests/test_fedoo_integration.py -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e2aaee8..84ddbdcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,3 +89,10 @@ jobs: name: test-logs-${{ matrix.os }} path: build/Testing/Temporary/LastTest.log retention-days: 7 + + - name: Install simcoon + fedoo and run integration tests (Unix) + if: runner.os != 'Windows' + run: | + pip install . --no-build-isolation + pip install "fedoo==0.8.0" pytest + pytest tests/test_fedoo_integration.py -v diff --git a/.github/workflows/fedoo-integration.yml b/.github/workflows/fedoo-integration.yml deleted file mode 100644 index 1dc8f726..00000000 --- a/.github/workflows/fedoo-integration.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Fedoo Integration - -on: - workflow_dispatch: - push: - branches: [master, fix/openMP] - pull_request: - branches: [master] - -permissions: - contents: read - actions: read - -jobs: - fedoo-integration: - strategy: - fail-fast: false - matrix: - fedoo-version: ["0.7.0", "0.6.1", "0.6.0"] - os: - - name: Linux - runner: ubuntu-latest - sys-deps: | - sudo apt-get update - sudo apt-get install -y libarmadillo-dev ninja-build - - name: macOS - runner: macos-latest - sys-deps: brew install armadillo ninja libomp - - runs-on: ${{ matrix.os.runner }} - name: ${{ matrix.os.name }} / fedoo ${{ matrix.fedoo-version }} - - steps: - - uses: actions/checkout@v6 - - - name: Install system dependencies - run: ${{ matrix.os.sys-deps }} - - - name: Setup uv - uses: astral-sh/setup-uv@v7 - - - name: Build simcoon wheel and install with fedoo - run: | - uv venv - uv pip install scikit-build-core pybind11 numpy - uv pip install . --no-build-isolation - uv pip install "fedoo==${{ matrix.fedoo-version }}" pytest - - - name: Verify OpenMP is not duplicated (macOS) - if: runner.os == 'macOS' - run: | - # Check that _core.so links to system libomp via RPATH, not a bundled one - uv run python -c " - import simcoon._core as c, os, pathlib - core = pathlib.Path(c.__file__) - pkg = core.parent - bundled = list(pkg.glob('*libomp*')) - assert not bundled, f'libomp should NOT be bundled in the wheel: {bundled}' - print('OK: no bundled libomp found in', pkg) - " - - - name: Run fedoo integration tests - run: uv run pytest tests/test_fedoo_integration.py -v diff --git a/python-setup/simcoon/rotation.py b/python-setup/simcoon/rotation.py index 83572195..cdc6cc7e 100644 --- a/python-setup/simcoon/rotation.py +++ b/python-setup/simcoon/rotation.py @@ -142,135 +142,212 @@ def from_scipy(cls, scipy_rot): return cls.from_quat(scipy_rot.as_quat()) # ------------------------------------------------------------------ - # Internal helper + # Internal helpers # ------------------------------------------------------------------ + @property + def _is_batch(self): + """True if this object stores more than one rotation.""" + return self.as_quat().ndim == 2 + def _to_cpp(self): - """Convert to a _CppRotation for C++ method dispatch.""" - return _CppRotation.from_quat(self.as_quat()) + """Convert to a _CppRotation for C++ method dispatch (single only).""" + q = self.as_quat() + if q.ndim == 2: + raise ValueError( + "Cannot convert a batch Rotation to a single _CppRotation. " + "Batch operations are handled automatically by the Python methods." + ) + return _CppRotation.from_quat(q) + + def _voigt_stress_matrices(self, active=True): + """Return QS matrices: (6,6) for single, (N,6,6) for batch.""" + q = self.as_quat() + if q.ndim == 1: + return _CppRotation.from_quat(q).as_voigt_stress_rotation(active) + return np.array([ + _CppRotation.from_quat(q[i]).as_voigt_stress_rotation(active) + for i in range(len(q)) + ]) + + def _voigt_strain_matrices(self, active=True): + """Return QE matrices: (6,6) for single, (N,6,6) for batch.""" + q = self.as_quat() + if q.ndim == 1: + return _CppRotation.from_quat(q).as_voigt_strain_rotation(active) + return np.array([ + _CppRotation.from_quat(q[i]).as_voigt_strain_rotation(active) + for i in range(len(q)) + ]) # ------------------------------------------------------------------ - # Mechanics methods (delegate to _CppRotation) + # Mechanics methods — support single and batch (Gauss-point) operations + # + # Single rotation: + # sigma (6,) → (6,) + # L (6,6) → (6,6) + # + # Batch of N rotations: + # sigma (6, N) → (6, N) one stress per rotation + # L (6, 6, N) → (6, 6, N) one stiffness per rotation # ------------------------------------------------------------------ def apply_stress(self, sigma, active=True): - """Apply rotation to a stress vector in Voigt notation. + """Apply rotation to stress vector(s) in Voigt notation. Parameters ---------- sigma : array_like - 6-component stress vector [s11, s22, s33, s12, s13, s23]. + Single (6,) stress vector, or (6, N) array for batch + (one column per Gauss point). active : bool, optional If True (default), active rotation. Returns ------- numpy.ndarray - Rotated stress vector. + Rotated stress: (6,) or (6, N). """ - return self._to_cpp().apply_stress(np.asarray(sigma, dtype=float), active).ravel() + sigma = np.asarray(sigma, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_stress(sigma.ravel(), active).ravel() + QS = self._voigt_stress_matrices(active) # (N, 6, 6) + return np.einsum("nij,jn->in", QS, sigma) def apply_strain(self, epsilon, active=True): - """Apply rotation to a strain vector in Voigt notation. + """Apply rotation to strain vector(s) in Voigt notation. Parameters ---------- epsilon : array_like - 6-component strain vector [e11, e22, e33, 2*e12, 2*e13, 2*e23]. + Single (6,) strain vector, or (6, N) for batch. active : bool, optional If True (default), active rotation. Returns ------- numpy.ndarray - Rotated strain vector. + Rotated strain: (6,) or (6, N). """ - return self._to_cpp().apply_strain(np.asarray(epsilon, dtype=float), active).ravel() + epsilon = np.asarray(epsilon, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_strain(epsilon.ravel(), active).ravel() + QE = self._voigt_strain_matrices(active) # (N, 6, 6) + return np.einsum("nij,jn->in", QE, epsilon) def apply_stiffness(self, L, active=True): - """Apply rotation to a 6x6 stiffness matrix. + """Apply rotation to 6x6 stiffness matrix/matrices. Parameters ---------- L : array_like - 6x6 stiffness matrix in Voigt notation. + Single (6, 6) stiffness matrix, or (6, 6, N) for batch. active : bool, optional If True (default), active rotation. Returns ------- numpy.ndarray - Rotated 6x6 stiffness matrix. + Rotated stiffness: (6, 6) or (6, 6, N). """ - return self._to_cpp().apply_stiffness(np.asarray(L, dtype=float), active) + L = np.asarray(L, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_stiffness(L, active) + QS = self._voigt_stress_matrices(active) # (N, 6, 6) + # L_rot = QS @ L @ QS^T for each n + return np.einsum("nij,jkn,nlk->iln", QS, L, QS) def apply_compliance(self, M, active=True): - """Apply rotation to a 6x6 compliance matrix. + """Apply rotation to 6x6 compliance matrix/matrices. Parameters ---------- M : array_like - 6x6 compliance matrix in Voigt notation. + Single (6, 6) compliance matrix, or (6, 6, N) for batch. active : bool, optional If True (default), active rotation. Returns ------- numpy.ndarray - Rotated 6x6 compliance matrix. + Rotated compliance: (6, 6) or (6, 6, N). """ - return self._to_cpp().apply_compliance(np.asarray(M, dtype=float), active) + M = np.asarray(M, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_compliance(M, active) + QE = self._voigt_strain_matrices(active) # (N, 6, 6) + # M_rot = QE @ M @ QE^T for each n + return np.einsum("nij,jkn,nlk->iln", QE, M, QE) def apply_strain_concentration(self, A, active=True): - """Apply rotation to a 6x6 strain concentration tensor. + """Apply rotation to 6x6 strain concentration tensor(s). Parameters ---------- A : array_like - 6x6 strain concentration tensor in Voigt notation. + Single (6, 6) tensor, or (6, 6, N) for batch. active : bool, optional If True (default), active rotation. Returns ------- numpy.ndarray - Rotated strain concentration tensor: QE * A * QS^T. + Rotated tensor: QE * A * QS^T. Shape (6, 6) or (6, 6, N). """ - return self._to_cpp().apply_strain_concentration(np.asarray(A, dtype=float), active) + A = np.asarray(A, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_strain_concentration(A, active) + QE = self._voigt_strain_matrices(active) # (N, 6, 6) + QS = self._voigt_stress_matrices(active) # (N, 6, 6) + return np.einsum("nij,jkn,nlk->iln", QE, A, QS) def apply_stress_concentration(self, B, active=True): - """Apply rotation to a 6x6 stress concentration tensor. + """Apply rotation to 6x6 stress concentration tensor(s). Parameters ---------- B : array_like - 6x6 stress concentration tensor in Voigt notation. + Single (6, 6) tensor, or (6, 6, N) for batch. active : bool, optional If True (default), active rotation. Returns ------- numpy.ndarray - Rotated stress concentration tensor: QS * B * QE^T. + Rotated tensor: QS * B * QE^T. Shape (6, 6) or (6, 6, N). """ - return self._to_cpp().apply_stress_concentration(np.asarray(B, dtype=float), active) + B = np.asarray(B, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_stress_concentration(B, active) + QS = self._voigt_stress_matrices(active) # (N, 6, 6) + QE = self._voigt_strain_matrices(active) # (N, 6, 6) + return np.einsum("nij,jkn,nlk->iln", QS, B, QE) def apply_tensor(self, m, inverse=False): - """Apply rotation to a 3x3 tensor (matrix). + """Apply rotation to 3x3 tensor(s). Parameters ---------- m : array_like - 3x3 tensor to rotate. + Single (3, 3) tensor, or (3, 3, N) for batch. inverse : bool, optional If True, apply inverse rotation. Default is False. Returns ------- numpy.ndarray - Rotated tensor: R * m * R^T (or R^T * m * R for inverse). + Rotated tensor: (3, 3) or (3, 3, N). """ - return self._to_cpp().apply_tensor(np.asarray(m, dtype=float), inverse) + m = np.asarray(m, dtype=float) + if not self._is_batch: + return self._to_cpp().apply_tensor(m, inverse) + # R matrices: (N, 3, 3) + R = self.as_matrix() + if inverse: + # R^T @ m @ R for each n + return np.einsum("nji,jkn,nkl->iln", R, m, R) + # R @ m @ R^T for each n + return np.einsum("nij,jkn,nlk->iln", R, m, R) def as_voigt_stress_rotation(self, active=True): """Get 6x6 rotation matrix for stress tensors in Voigt notation. @@ -283,9 +360,9 @@ def as_voigt_stress_rotation(self, active=True): Returns ------- numpy.ndarray - 6x6 stress rotation matrix (QS). + Single (6, 6) or batch (N, 6, 6) stress rotation matrix (QS). """ - return self._to_cpp().as_voigt_stress_rotation(active) + return self._voigt_stress_matrices(active) def as_voigt_strain_rotation(self, active=True): """Get 6x6 rotation matrix for strain tensors in Voigt notation. @@ -298,9 +375,9 @@ def as_voigt_strain_rotation(self, active=True): Returns ------- numpy.ndarray - 6x6 strain rotation matrix (QE). + Single (6, 6) or batch (N, 6, 6) strain rotation matrix (QE). """ - return self._to_cpp().as_voigt_strain_rotation(active) + return self._voigt_strain_matrices(active) # ------------------------------------------------------------------ # Compatibility helpers diff --git a/simcoon-python-builder/test/test_core/test_rotation.py b/simcoon-python-builder/test/test_core/test_rotation.py index a9f43a50..2771e9d9 100644 --- a/simcoon-python-builder/test/test_core/test_rotation.py +++ b/simcoon-python-builder/test/test_core/test_rotation.py @@ -291,3 +291,100 @@ def test_from_axis_angle_degrees(self): def test_from_axis_angle_invalid_axis(self): with pytest.raises(ValueError, match="axis must be 1, 2, or 3"): sim.Rotation.from_axis_angle(0.5, 4) + + +# =================================================================== +# 6. Batch (Gauss-point) operations +# =================================================================== + +class TestBatch: + """Batch rotation operations on (6, N) / (6, 6, N) arrays, + matching the Gauss-point data layout used by simcoon and fedoo.""" + + @pytest.fixture + def n_gauss(self): + return 64 + + @pytest.fixture + def batch_rot(self, n_gauss): + return sim.Rotation.random(n_gauss) + + def test_batch_apply_stress(self, batch_rot, n_gauss): + sigma = np.random.rand(6, n_gauss) * 100 + result = batch_rot.apply_stress(sigma) + assert result.shape == (6, n_gauss) + for i in range(n_gauss): + expected = batch_rot[i].apply_stress(sigma[:, i]) + np.testing.assert_allclose(result[:, i], expected, atol=1e-10) + + def test_batch_apply_strain(self, batch_rot, n_gauss): + eps = np.random.rand(6, n_gauss) * 0.01 + result = batch_rot.apply_strain(eps) + assert result.shape == (6, n_gauss) + for i in range(n_gauss): + expected = batch_rot[i].apply_strain(eps[:, i]) + np.testing.assert_allclose(result[:, i], expected, atol=1e-10) + + def test_batch_apply_stiffness(self, batch_rot, n_gauss): + L_single = sim.L_iso([70000, 0.3], "Enu") + L = np.repeat(L_single[:, :, np.newaxis], n_gauss, axis=2) + result = batch_rot.apply_stiffness(L) + assert result.shape == (6, 6, n_gauss) + for i in range(n_gauss): + expected = batch_rot[i].apply_stiffness(L_single) + np.testing.assert_allclose(result[:, :, i], expected, atol=1e-10) + + def test_batch_apply_compliance(self, batch_rot, n_gauss): + M_single = sim.M_iso([70000, 0.3], "Enu") + M = np.repeat(M_single[:, :, np.newaxis], n_gauss, axis=2) + result = batch_rot.apply_compliance(M) + assert result.shape == (6, 6, n_gauss) + for i in range(n_gauss): + expected = batch_rot[i].apply_compliance(M_single) + np.testing.assert_allclose(result[:, :, i], expected, atol=1e-10) + + def test_batch_apply_tensor(self, batch_rot, n_gauss): + T_single = np.array([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]) + T = np.repeat(T_single[:, :, np.newaxis], n_gauss, axis=2) + result = batch_rot.apply_tensor(T) + assert result.shape == (3, 3, n_gauss) + for i in range(n_gauss): + expected = batch_rot[i].apply_tensor(T_single) + np.testing.assert_allclose(result[:, :, i], expected, atol=1e-10) + + def test_batch_voigt_stress_rotation(self, batch_rot, n_gauss): + QS = batch_rot.as_voigt_stress_rotation() + assert QS.shape == (n_gauss, 6, 6) + for i in range(n_gauss): + expected = batch_rot[i].as_voigt_stress_rotation() + np.testing.assert_allclose(QS[i], expected, atol=1e-10) + + def test_batch_isotropic_invariance(self, batch_rot, n_gauss): + """Isotropic stiffness must be invariant under any rotation.""" + L_single = sim.L_iso([200e3, 0.3], "Enu") + L = np.repeat(L_single[:, :, np.newaxis], n_gauss, axis=2) + L_rot = batch_rot.apply_stiffness(L) + for i in range(n_gauss): + np.testing.assert_allclose(L_rot[:, :, i], L_single, atol=1e-6) + + def test_batch_roundtrip_stress(self, batch_rot, n_gauss): + sigma = np.random.rand(6, n_gauss) * 100 + sigma_rot = batch_rot.apply_stress(sigma) + sigma_back = batch_rot.apply_stress(sigma_rot, active=False) + np.testing.assert_allclose(sigma_back, sigma, atol=1e-10) + + def test_batch_from_matrix_dr_cube(self, n_gauss): + """Create batch Rotation from (3, 3, N) DR cube (fedoo layout).""" + rotations = sim.Rotation.random(n_gauss) + DR = rotations.as_matrix().transpose(1, 2, 0) # (3, 3, N) + assert DR.shape == (3, 3, n_gauss) + + rotations2 = sim.Rotation.from_matrix(DR.transpose(2, 0, 1)) + assert len(rotations2) == n_gauss + + sigma = np.random.rand(6, n_gauss) * 100 + np.testing.assert_allclose( + rotations.apply_stress(sigma), + rotations2.apply_stress(sigma), + atol=1e-10, + ) diff --git a/tests/test_fedoo_integration.py b/tests/test_fedoo_integration.py index 934f8649..2c086250 100644 --- a/tests/test_fedoo_integration.py +++ b/tests/test_fedoo_integration.py @@ -36,14 +36,8 @@ def _apply_compression_bc(pb, mesh, disp_z): pb.bc.add("Dirichlet", mesh.find_nodes("Z", mesh.bounding_box.zmax), "DispZ", disp_z) -# ── Test 1: Non-linear UMAT (EPICP) ─ small strain ─ exercises OpenMP ── - def test_epicp_small_strain(): - """Elastoplastic EPICP umat under small-strain compression. - - This exercises the OpenMP-parallelised umat call (multi-point - stress integration) with a non-linear material (plasticity). - """ + """Elastoplastic EPICP umat, small strain (exercises OpenMP).""" mesh = _make_cube("epicp_ss") props = np.array([200e3, 0.3, 0.0, 300.0, 1000.0, 0.5]) mat = fedoo.constitutivelaw.Simcoon("EPICP", props, name="epicp_ss_mat") @@ -58,20 +52,12 @@ def test_epicp_small_strain(): disp = pb.get_disp("DispZ") assert np.max(np.abs(disp)) == pytest.approx(0.01, abs=1e-8) - - # Verify plastic deformation occurred (statev[1] = accumulated plastic strain p) p = asm.sv["Statev"][1] assert np.max(p) > 0, "Expected plastic deformation" -# ── Test 2: Non-linear geometry (Neo-Hookean) ─ tangent modulus transfer ── - def test_neohookean_nlgeom(): - """Neo-Hookean hyperelastic under large-strain compression. - - This exercises non-linear geometry (nlgeom=True) which involves - deformation gradient transfer and tangent modulus computation. - """ + """Neo-Hookean hyperelastic, large strain (tangent modulus transfer).""" mesh = _make_cube("neohc_nl") props = np.array([80e3, 400e3]) # mu, kappa mat = fedoo.constitutivelaw.Simcoon("NEOHC", props, name="neohc_nl_mat") @@ -86,23 +72,12 @@ def test_neohookean_nlgeom(): disp = pb.get_disp("DispZ") assert np.max(np.abs(disp)) == pytest.approx(0.1, abs=1e-8) - - # Verify deformation gradient was tracked F = asm.sv["F"] - assert F is not None - assert not np.allclose(F, np.eye(3).reshape(3, 3, 1)), \ - "Deformation gradient should differ from identity" - + assert not np.allclose(F, np.eye(3).reshape(3, 3, 1)) -# ── Test 3: EPICP + nlgeom ─ OpenMP umat + tangent modulus + large strain ── def test_epicp_nlgeom(): - """Elastoplastic EPICP under large-strain compression. - - This is the most demanding test: non-linear material (OpenMP umat) - combined with non-linear geometry (deformation gradient and tangent - modulus transfer between fedoo and simcoon). - """ + """EPICP + large strain (OpenMP umat + tangent modulus + F transfer).""" mesh = _make_cube("epicp_nl") props = np.array([200e3, 0.3, 0.0, 300.0, 1000.0, 0.5]) mat = fedoo.constitutivelaw.Simcoon("EPICP", props, name="epicp_nl_mat") @@ -117,40 +92,25 @@ def test_epicp_nlgeom(): disp = pb.get_disp("DispZ") assert np.max(np.abs(disp)) == pytest.approx(0.05, abs=1e-8) - - # Verify plastic deformation p = asm.sv["Statev"][1] assert np.max(p) > 0, "Expected plastic deformation" - - # Verify deformation gradient was tracked F = asm.sv["F"] assert not np.allclose(F, np.eye(3).reshape(3, 3, 1)) - - # Verify tangent matrix is populated and symmetric Lt = asm.sv["TangentMatrix"] - assert Lt is not None sym_err = np.abs(Lt - Lt.transpose((1, 0, 2))).max() assert sym_err < 1e-3, f"Tangent matrix not symmetric (err={sym_err})" -# ── Test 4: Direct sim.umat call with explicit n_threads ── - @pytest.mark.parametrize("n_threads", [1, 2, 4]) def test_umat_openmp_threads(n_threads): - """Call sim.umat directly with explicit n_threads to verify OpenMP works. - - With multiple Gauss points the umat loop is OpenMP-parallelised. - Results must be identical regardless of thread count. - """ - n_gauss = 64 # simulate 64 Gauss points (like 8 hex8 elements) + """Direct sim.umat with explicit n_threads to verify OpenMP.""" + n_gauss = 64 props = np.asfortranarray( np.tile([200e3, 0.3, 0.0, 300.0, 1000.0, 0.5], (n_gauss, 1)).T ) - etot = np.zeros((6, n_gauss), order="F") - # Apply a uniaxial strain increment that exceeds yield Detot = np.zeros((6, n_gauss), order="F") - Detot[2, :] = 5e-3 # eps_zz increment + Detot[2, :] = 5e-3 sigma = np.zeros((6, n_gauss), order="F") statev = np.zeros((8, n_gauss), order="F") @@ -165,9 +125,6 @@ def test_umat_openmp_threads(n_threads): 0.0, 1.0, Wm, n_threads=n_threads, ) - # All Gauss points have identical input → identical output assert np.allclose(stress[:, 0:1], stress, atol=1e-6), \ f"Stress not uniform across Gauss points with n_threads={n_threads}" - - # Should have yielded (sigma_Y = 300 MPa, E*eps = 200e3*5e-3 = 1000 MPa) - assert sv[1, 0] > 0, "Expected plastic strain with n_threads={n_threads}" + assert sv[1, 0] > 0, f"Expected plastic strain with n_threads={n_threads}" From 987d241b8e6dd483abb2100744126942cc7aeb87 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 12 Mar 2026 08:43:19 +0100 Subject: [PATCH 4/8] Use fedoo GitHub source until v0.8.0 release Update CI workflows to install fedoo directly from the GitHub main branch instead of pinning to v0.8.0. build.yml: replace the pinned install with a git+https install and add a commented suggestion to use >=0.8.0 when released. ci.yml: add scikit-build-core, pybind11 and numpy to the install step, keep local package install, and install fedoo from the GitHub repo (with a commented line for >=0.8.0); both workflows continue to run the fedoo integration tests. --- .github/workflows/build.yml | 3 ++- .github/workflows/ci.yml | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2444fb15..182f52a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,5 +76,6 @@ jobs: - name: Install fedoo and run integration tests (Unix) if: runner.os != 'Windows' run: | - uv pip install "fedoo==0.8.0" + # uv pip install "fedoo>=0.8.0" # uncomment when fedoo 0.8.0 is released + uv pip install "git+https://github.com/3MAH/fedoo.git@main" uv run pytest tests/test_fedoo_integration.py -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84ddbdcd..9f1bfbaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,8 @@ jobs: - name: Install simcoon + fedoo and run integration tests (Unix) if: runner.os != 'Windows' run: | + pip install scikit-build-core pybind11 numpy pip install . --no-build-isolation - pip install "fedoo==0.8.0" pytest + # pip install "fedoo>=0.8.0" pytest # uncomment when fedoo 0.8.0 is released + pip install "git+https://github.com/3MAH/fedoo.git@main" pytest pytest tests/test_fedoo_integration.py -v From a7b49f1d65789731e16bb471716b2c7c834d7228 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 12 Mar 2026 10:18:45 +0100 Subject: [PATCH 5/8] Use master branch for fedoo installs Update CI and build workflows to install fedoo from the repository's master branch instead of main. The pip install URLs in .github/workflows/build.yml and .github/workflows/ci.yml were changed from @main to @master so integration tests pull the correct branch. --- .github/workflows/build.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 182f52a9..33247d1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,5 +77,5 @@ jobs: if: runner.os != 'Windows' run: | # uv pip install "fedoo>=0.8.0" # uncomment when fedoo 0.8.0 is released - uv pip install "git+https://github.com/3MAH/fedoo.git@main" + uv pip install "git+https://github.com/3MAH/fedoo.git@master" uv run pytest tests/test_fedoo_integration.py -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f1bfbaf..5ab3fd6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,5 +96,5 @@ jobs: pip install scikit-build-core pybind11 numpy pip install . --no-build-isolation # pip install "fedoo>=0.8.0" pytest # uncomment when fedoo 0.8.0 is released - pip install "git+https://github.com/3MAH/fedoo.git@main" pytest + pip install "git+https://github.com/3MAH/fedoo.git@master" pytest pytest tests/test_fedoo_integration.py -v From 76e0930b7f114488dcfe1d2e7f379bb7e53f28a5 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 13 Mar 2026 09:45:38 +0100 Subject: [PATCH 6/8] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33247d1f..e7debad0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,5 +77,5 @@ jobs: if: runner.os != 'Windows' run: | # uv pip install "fedoo>=0.8.0" # uncomment when fedoo 0.8.0 is released - uv pip install "git+https://github.com/3MAH/fedoo.git@master" + uv pip install "git+https://github.com/3MAH/fedoo.git@feature/rotation" uv run pytest tests/test_fedoo_integration.py -v From f66617a1a7b738f0f847ce5854be8497421f0f40 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 13 Mar 2026 14:24:46 +0100 Subject: [PATCH 7/8] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3a1d15b9..098696af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "scikit_build_core.build" [project] name = "simcoon" -version = "1.10.2" +version = "1.11.0" description = "Simulation in Mechanics and Materials: Interactive Tools - A library for the simulation of multiphysics systems and heterogeneous materials" readme = "README.md" license = {text = "GPL-3.0-or-later"} From c6e399cf9a0635416d84328ca64a5b38f68164fd Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Sat, 14 Mar 2026 17:20:08 +0100 Subject: [PATCH 8/8] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab3fd6c..f7886d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,5 +96,5 @@ jobs: pip install scikit-build-core pybind11 numpy pip install . --no-build-isolation # pip install "fedoo>=0.8.0" pytest # uncomment when fedoo 0.8.0 is released - pip install "git+https://github.com/3MAH/fedoo.git@master" pytest + pip install "git+https://github.com/3MAH/fedoo.git@feature/rotation" pytest pytest tests/test_fedoo_integration.py -v