Skip to content

Add Drucker-Prager plasticity with apex return - #10

Open
petlenz wants to merge 25 commits into
mainfrom
feature/drucker-prager
Open

Add Drucker-Prager plasticity with apex return#10
petlenz wants to merge 25 commits into
mainfrom
feature/drucker-prager

Conversation

@petlenz

@petlenz petlenz commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

Adds Drucker-Prager (DP) plasticity with non-associative flow and apex return
to the existing J2 framework. The same small_strain_plasticity template now
handles both J2 and DP via a yield-function policy. Also fixes a class of
tangent-consistency bugs and adds extensive documentation.

What's new

Drucker-Prager yield function

  • drucker_prager_yield_function<T, Dim> policy with friction η, dilatancy
    β, bulk modulus K_bulk
  • Non-associative flow rule (η ≠ β) — yield normal M = dF/dσ and flow
    normal N = dG/dσ are distinct
  • Stateful policy passed via parameter handler at construction
  • Type alias: drucker_prager_plasticity<Traits> mirrors j2_plasticity<Traits>

Apex return

  • Detects when standard return would push √J₂ below zero (pressure-dominated
    loading)
  • Pre-checks the apex condition from trial quantities to avoid wasted smooth
    Newton iterations
  • Dedicated apex residual: r = η·p_trial - K·η·β·Δκ - k - H = 0
  • Rank-1 volumetric apex tangent: K·H' / (K·η·β + H') · I⊗I
  • Compile-time dispatch via has_apex_return concept — J2 path is unaffected

Tangent consistency fixes

  • Original DP tangent had a constant ~1.8% error from a pressure-term
    mismatch between modified_equivalent_stress and yield_normal
  • Root cause: √J₂ + η·I₁ paired with s/(2q) + η/3·I (factor-of-3 mismatch
    in the volumetric coupling)
  • Fixed by unifying on √J₂ + η·p convention everywhere
  • Tangent now uses trial-state quantities (matches the algorithm: ε_p update
    uses N_trial)
  • DP tangent error now ~10⁻¹⁰ (machine precision); J2 tangent improved from
    ~10⁻⁴ to ~10⁻¹⁰ as a side effect

Refactoring

  • Extracted shared helpers in plasticity_utils.h: evaluate_at_state,
    compute_trial, compute_tangent, make_IIdev
  • small_strain_plasticity::compute() is now a flat dispatch: elastic /
    apex / smooth — each path is a named private member function
  • solve_scalar_return(phi, G_eff, kappa_n) is shared between smooth and
    apex Newton (matches the unified residual structure)
  • Renamed hardening variable α → κ (avoids clash with DP friction
    coefficient; matches the document notation)
  • DP friction coefficient renamed α → η in code

Documentation

  • New docs/small_strain_plasticity.md: full derivation of yield functions,
    return-mapping algorithm, consistent tangent (smooth and apex), policy
    interface, consistency requirements, and common failure modes
  • Includes the J2-to-DP normalization relationship and stress sign convention

Tests and tooling

  • test_drucker_prager.cpp: 6 tests covering elastic-before-yield, yielding,
    volumetric plastic strain, pressure sensitivity, consistent tangent, and
    tangent convergence (max error 5.2e-10)
  • scripts/plot_plasticity.py + tests/plot_data.cpp: stress-strain and
    tangent-accuracy plots for three loading paths (ε₁₁, ε₂₂, ε₁₂)
  • tests/debug_apex.cpp: standalone diagnostic for the apex-overshoot
    condition

Bug fixes (also applied to existing code)

  • rk_plasticity.h: jacobian(m_G, ...) should be
    jacobian(effective_modulus(m_G), ...) — pre-existing scaling bug
  • backward_euler::solve(): now reports convergence via converged() and
    clamps result to ≥ 0 (no backward plastic flow)
  • small_strain_plasticity::compute(): throws on non-convergence (was silent
    wrong-result bug); falls back to apex if smooth Newton fails and apex
    support exists
  • apex_tangent: scale-relative zero-denominator threshold
    (eps · (|Kηβ| + |H'|)) instead of numeric_limits::min()
  • flow_normal_stress_derivative now takes sig_dev directly — avoids
    cancellation error from reconstructing s from non-associative N
  • evaluate_at_state threshold scaled to σ_0 — prevents subnormal-stress
    divisions in downstream 1/J₂ terms

Test plan

  • All 38 existing tests pass (5 suites: J2, DP, RK, materials, damage)
  • DP consistent-tangent test passes at machine precision (~10⁻¹⁰)
  • DP convergence test: max tangent error 5.2e-10 (was 1.84e-02 before
    fixes)
  • J2 tests still pass — no regression from the unified policy interface
  • Apex return verified on uniaxial-strain loading (oscillating-stress
    bug fixed)
  • Three loading directions (ε₁₁, ε₂₂, ε₁₂) tested and plotted

petlenz added 18 commits April 22, 2026 09:41
- small_strain_plasticity<Traits, YieldFunction>: generic return mapping
  - Consistent tangent via implicit function theorem (generic, not per yield fn)
  - Internal Newton via solver.solve(eval) — solver is an external material
- j2_yield_function: stateless policy with 6 static methods
- linear_isotropic_hardening, exponential_isotropic_hardening
- material_ref<T>: lazy material references resolved at finalize()
  - add_material_ref<T>(name) in material_interface
  - wire_materials() called before wire_inputs() in finalize()
- backward_euler: dual-mode (graph-driven update + direct solve call)
- 6 new tests (elastic, yielding, yield surface, deviatoric, hardening, tangent)
- All 23 tests pass
- material_ref: debug assert on null dereference
- material_interface: defaulted destructor, batch missing-material errors
- object_store: string_view in find()
- property_engine: dump() to stderr
- json_parameter_converter: warnings to stderr
- material_context: consistent include guard
- test_j2: tighten yield surface tolerance from 10 MPa to 1 MPa
- test_property_graph: add missing-parameter and circular-dependency error tests
- 25/25 tests pass
- butcher_tableau: runtime data + 7 factory functions
- explicit_rk_integrator: no solver needed
- dirk_integrator: Newton per diagonal stage
- implicit_rk_integrator: coupled Newton (Gaussian elimination)
- curing_rate: pure rate function for autocatalytic curing (RK-compatible)
- autocatalytic_reaction: added rate/rate_derivative outputs
- 12 new tests: convergence order (exponential decay) + curing simulation
  - Forward Euler order 1, RK4 order 4, implicit midpoint order 2,
    Crank-Nicolson order 2, Gauss-Legendre order 4
  - Curing converges with RK4, implicit midpoint, and Gauss-Legendre
- All 37 tests pass
…leau)

- rk_integrator: single class handles explicit/DIRK/fully implicit
  (dispatches at construction based on tableau structure)
- small_strain_plasticity: optional tableau parameter for multi-stage
  return mapping. Without tableau → solver.solve() (classical).
  With tableau → multi-stage RK stages.
- Delete: explicit_rk_integrator, dirk_integrator, implicit_rk_integrator,
  rk_plasticity, plasticity_integrator, j2_constitutive_law
- solver_source now optional (not needed with tableau)
- 40/40 tests pass
- plasticity_utils.h: free functions compute_trial() and compute_tangent()
- small_strain_plasticity: lean single-stage, no tableau overhead
- rk_plasticity: multi-stage with tableau, uses same utils
- No dead member storage in the simple class
- 40/40 tests pass

@petlenz petlenz left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review from the numsim-codegen side

Reviewing with the codegen hat on: numsim-codegen's NumSimMaterialTarget generates constitutive materials that target exactly this runtime's solver/property contract (<function>::rate/rate_derivative and residual/jacobian over Local edges, driven by update_source()). So this solver layer is shared infrastructure and changes ripple into generated code. Overall this is careful, well-structured plasticity — findings are mostly about the generic solver picking up domain-specific behavior, plus one tangent-consistency question.

Done well

  • Scalar return mapping in full-tensor representation. The local unknown is the scalar multiplier Δλ; the tensor update is closed-form (ε_p += Δλ·N). That's why a scalar solver suffices and there is no Voigt/Mandel flattening. (So the Mandel + vector-solver gaps I filed — #11/#12 — are for a different class, coupled tensor-valued local systems, not this. This PR correctly sidesteps them.)
  • Convergence-failure handling is robust in small_strain_plasticity::compute() — checks converged(), falls back to apex, and throws if both fail rather than using a bad iterate. Better rigor than codegen's current in-function Newton (numsim-codegen#85).
  • The conservative apex pre-check (dλ_max = F_trial/G_eff) to skip a doomed smooth Newton is a nice optimization.

Findings

HIGH — backward_euler is a generic name with domain-specific behavior baked in. (solvers/backward_euler.h)

  • update() ends with m_delta = std::abs(m_delta);"curing degree can only increase" (:80). A curing-specific monotonicity assumption hardcoded into a generic solver.
  • solve() clamps every result with std::max(x, value_type{0})"negative plastic-multiplier unphysical" (:93,94,98). A plasticity-specific assumption, also in the generic solver.

So "backward_euler" is really two domain-specific solvers wearing a generic name. This blocks codegen reuse: a generated material whose state can legitimately decrease or go negative would be silently corrupted by abs()/max(·,0). Suggest hoisting the sign/clamp policy out of the solver (a clamp_nonnegative parameter defaulting off, or keep the clamp in the plasticity caller).

MEDIUM — update() and solve() disagree on convergence reporting. solve() sets m_converged; update() never does — on max_iter exhaustion it silently proceeds with the last iterate. The graph-driven path (curing, and any codegen rate material wired through it) has no non-convergence signal. Mirror converged() into update().

MEDIUM — Drucker-Prager algorithmic tangent uses the trial flow direction. materials/small_strain_plasticity.h:168-169. For DP the converged flow direction differs from trial, so N_trial gives a tangent that is not the consistent algorithmic tangent — degrading host-FE Newton from quadratic toward linear near yielding. The material advertises a tangent output; is the trial-direction tangent an intentional documented approximation, or should DP assemble at the converged direction?

LOW — magic constants in the solver. The m_delta = 5e-12 seed (:59), the 1e-30 singular-Jacobian threshold (:94), and the 5-iteration NaN-damping cap should be named/parameterized.

Cross-repo note

The residual/jacobian (and rate/rate_derivative) Local-edge contract itself is clean and is what codegen emits against. The one thing currently preventing a generated material from reusing backward_euler as-is is the HIGH finding (the baked-in sign policy). Hoist that out and this solver becomes directly consumable by codegen output — the goal of the graph-coupled architecture.

auto x = x0;
for (int i = 0; i < m_max_iter; ++i) {
auto [r, dr] = eval(x);
if (std::abs(r) < m_tol) { m_converged = true; return std::max(x, value_type{0}); }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (codegen-relevant): std::max(x, 0) bakes a plasticity assumption (Δλ ≥ 0) into a generically-named solver. A codegen-generated material solving a general residual whose root is legitimately negative would be silently clamped to 0. Suggest moving the clamp into the plasticity caller, or gating it behind a clamp_nonnegative parameter (default off).

m_stress = tmech::dcontract(C_e, m_strain.get() - m_eps_p.new_value());

// Tangent at trial state (return mapping uses N_trial).
// For J2, trial = converged. For DP, they differ.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trial-direction tangent is not the consistent algorithmic tangent for DP (N differs trial vs converged), which degrades host-FE Newton convergence near yielding. Intentional documented approximation, or should the DP tangent be assembled at the converged flow direction? The material advertises a tangent output, so worth pinning down.

The pull_request trigger filtered on branches: [main], so a PR targeting a
feature branch matched nothing. Work here lands through stacks -- #27..#31 all
target a feature branch -- and not one of them has ever been built or tested by
CI. The last run of any kind was main, three weeks ago.

Dropping the filter runs the job for every pull request whatever its base. The
push trigger keeps its main filter, so branch pushes add no load: a stacked
branch is covered by its own PR.
EIGEN_BUILD_TESTING is only honoured by Eigen after 3.4.0; the pinned tag gates
on BUILD_TESTING, so the existing guard did nothing. Any build that FETCHES
Eigen -- every build on a machine without it installed, i.e. CI -- got Eigen's
whole suite in its ctest run. Measured here: 987 tests, 835 of them failing,
against 46 of our own.

Both variables are set so a bumped tag stays covered. Our tests are unaffected:
they register through enable_testing(), not BUILD_TESTING.

Reproduced with -DCMAKE_DISABLE_FIND_PACKAGE_Eigen3=ON, which is what a clean
runner does. 46/46 after.
The previous fix forced BUILD_TESTING OFF to stop a fetched Eigen registering
its ~780 tests into our ctest run. That worked, but BUILD_TESTING is a GLOBAL
variable, and it only left our own tests standing because they register through
a bare enable_testing(). Anyone switching this project to include(CTest) would
have silently dropped the entire suite.

SOURCE_SUBDIR names a directory with no CMakeLists.txt, so MakeAvailable
populates Eigen and stops -- add_subdirectory is never called and none of
Eigen's CMake runs. Eigen is header-only, so the source dir is the include
path and nothing is lost. It is the treatment tmech and nlohmann already get
here: take the headers, leave the build system alone.

Verified with -DCMAKE_DISABLE_FIND_PACKAGE_Eigen3=ON:
  - 46/46, ours alone
  - 46/46 again with -DBUILD_TESTING=ON, so the coupling is gone rather than
    merely satisfied
  - _deps/eigen-build contains 0 files
A raw ${eigen_SOURCE_DIR} in the INTERFACE_INCLUDE_DIRECTORIES of an EXPORTED
target is rejected at generate time:

  Target "numsim-materials" INTERFACE_INCLUDE_DIRECTORIES property contains
  path: .../build/_deps/eigen-src  which is prefixed in the build directory.

BUILD_INTERFACE scopes it to the build tree, which is all it can describe -- a
consumer of the INSTALLED package supplies its own Eigen, as it already does
for tmech.

Missed locally because the check piped configure to /dev/null and relied on
&&, and CMake still writes usable build files after a generate error: the
build and all 46 tests ran green on top of a failed configure.
The project called enable_testing() directly, so BUILD_TESTING -- the switch
consumers expect to reach -- did not exist. include(CTest) declares it and
calls enable_testing() itself.

Gated on PROJECT_IS_TOP_LEVEL: embedded in a superproject, that project owns
the dashboard targets, and its BUILD_TESTING choice should reach us rather than
be re-declared. Embedded without one, BUILD_TESTING defaults ON so behaviour is
unchanged for existing consumers.

Both switches are kept and either turns tests off: BUILD_TESTING is how a
superproject silences every subproject at once, NUMSIM_BUILD_TESTS only ours.

This was NOT adoptable before the previous commit. Suppressing a fetched
Eigen's ~780 tests meant forcing BUILD_TESTING OFF globally, which under
include(CTest) would have silenced our own suite as well. Eigen's CMake no
longer runs at all, so the name is free. Nothing else gates on it -- numsim-core
uses BUILD_TESTS, tmech TMECH_BUILD_TESTS, nlohmann JSON_BuildTests, all forced
off by name.

Verified: default 46/46; BUILD_TESTING=OFF and NUMSIM_BUILD_TESTS=OFF each skip
the suite; BUILD_TESTING=ON gives 46 again rather than Eigen's.

@petlenz petlenz left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical review — probed rather than read. One high finding, plus a pre-existing packaging defect that shows up here because this is the earliest open PR.

severity finding
high the apex return is reached by no test in the suite
medium the INSTALLED package cannot be consumed (pre-existing on main)

The installed package is unusable

Not introduced by this PR — I reproduced it on main — but every PR ships install(EXPORT ...) and CI never runs install, so nothing catches it:

find_package(numsim-materials REQUIRED)

CMake Error: ... set numsim-materials_FOUND to FALSE ...
  The following imported targets are referenced, but are missing:
  numsim-core::numsim-core

numsim-core is FetchContent'd into our build and never exported, so the generated numsim-materialsTargets.cmake names a target no consumer can resolve. The install succeeds; the result is just not usable. A find_dependency(numsim-core) in numsim-materialsConfig.cmake.in plus exporting or requiring it would close it — out of scope here, but worth an issue since it silently affects every release.

While checking that, I confirmed the normal developer path still works after today's dependency churn: with Eigen and nlohmann found rather than fetched, configure/build/test are clean at 216/216 on the tip of the stack. I had been testing only the fetched path.

/// Check if the standard return overshoots the DP cone apex.
/// When G_shear*Δλ ≥ q_trial, the deviatoric correction flips direction.
/// G_shear is the plain shear modulus (not G_eff).
bool needs_apex_return(T G_shear, T dlambda, T sqrt_j2) const {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding (high): nothing in the test suite ever reaches the apex branch.

I instrumented this predicate with a counter and ran every test binary in the suite:

NO test binary in the suite ever reaches the apex branch

APEX_HITS=0, including test_drucker_prager itself. So needs_apex_return, apex_modified_sig_eq, apex_effective_modulus, apex_plastic_strain and apex_tangent — roughly fifty lines, and the subject of this PR's most recent commit — are executed by nothing.

That matters more here than it usually would, because the code itself says the apex is the hard part:

The return map is nonsmooth at the apex, so this is not a unique classical derivative. [...] the apex branch is rate-indifferent and has no stiffness.

A branch that is documented as nonsmooth, non-unique and stiffness-free is exactly the one whose behaviour you want pinned by a test, and DPConvergence.TangentErrorIsBounded cannot pin it: it drives a single path at 0.02 increments that stays on the smooth cone the whole way, so the tangent it validates is the cone tangent.

tests/debug_apex.cpp exists but is an add_executable, not add_numsim_test — CI compiles it and never runs it, and it asserts nothing. That is the shape that makes this easy to miss: there IS apex code in tests/, it just is not a test.

What would close it: a load path in high triaxial tension that provably enters the branch (assert on a state the apex produces, e.g. q == 0 within tolerance), plus the tangent checker run on that path so the apex tangent is compared against a numerical derivative rather than asserted by construction. If the honest answer is that the apex tangent cannot be FD-verified because the map is nonsmooth there, then a test that documents the chosen convention is still worth more than none.

Nothing in the suite reached it. Instrumenting needs_apex_return() with a
counter and running every test binary gave APEX_HITS=0: apex_modified_sig_eq,
apex_effective_modulus, apex_plastic_strain and apex_tangent were executed by
no test. tests/debug_apex.cpp is an add_executable, not add_numsim_test, so CI
compiled it and never ran it.

Every existing path is uniaxial and stays on the smooth cone, which is why
DPConvergence.TangentErrorIsBounded cannot cover the apex: it validates the
cone tangent. The apex sits on the hydrostatic axis, and
tensor_component_stepper moves one component at a time, so the tests carry a
small hydrostatic driver.

Two tests: hydrostatic tension drives the deviatoric stress to zero, which the
smooth branch cannot do (there the return is proportional to a nonzero
s_trial); and the resulting state is admissible -- pressure capped by the
hardening-shifted cone tip k/eta, plastic volume change positive for beta > 0.

Same counter after the change: APEX_HITS=37.

cmake: the installed package could not be consumed. find_package() failed with
'the following imported targets are referenced, but are missing:
numsim-core::numsim-core' -- the exported set names every target linked
INTERFACE and the generated Config re-found none of them. It now re-finds
whatever was linked at build time; a FETCHED dependency is header-only, reaches
the consumer through BUILD_INTERFACE, and is correctly absent from the list.

Pre-existing on main, and invisible because CI never runs install.
@petlenz

petlenz commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

The find_dependency fix in this PR makes find_package(numsim-materials) succeed, but a consumer still cannot compile: numsim-core's exported target carries no include directory, so the headers it resolves have no path.

Root-caused and filed upstream as NumSim-Stack/numsim-core#18include(GNUInstallDirs) sits 18 lines after the ${CMAKE_INSTALL_INCLUDEDIR} that depends on it, so the INSTALL_INTERFACE expands empty. One-line fix, verified.

The two defects are independent; fixing this one exposed that one. Consuming an installed numsim-materials needs both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant