Add Drucker-Prager plasticity with apex return - #10
Conversation
- 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
…ial return is exact)
petlenz
left a comment
There was a problem hiding this comment.
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()— checksconverged(), 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 withm_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 withstd::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}); } |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
Summary
Adds Drucker-Prager (DP) plasticity with non-associative flow and apex return
to the existing J2 framework. The same
small_strain_plasticitytemplate nowhandles 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 modulusK_bulkη ≠ β) — yield normalM = dF/dσand flownormal
N = dG/dσare distinctdrucker_prager_plasticity<Traits>mirrorsj2_plasticity<Traits>Apex return
√J₂below zero (pressure-dominatedloading)
Newton iterations
r = η·p_trial - K·η·β·Δκ - k - H = 0K·H' / (K·η·β + H') · I⊗Ihas_apex_returnconcept — J2 path is unaffectedTangent consistency fixes
mismatch between
modified_equivalent_stressandyield_normal√J₂ + η·I₁paired withs/(2q) + η/3·I(factor-of-3 mismatchin the volumetric coupling)
√J₂ + η·pconvention everywhereuses
N_trial)~10⁻⁴ to ~10⁻¹⁰ as a side effect
Refactoring
plasticity_utils.h:evaluate_at_state,compute_trial,compute_tangent,make_IIdevsmall_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 andapex Newton (matches the unified residual structure)
α → κ(avoids clash with DP frictioncoefficient; matches the document notation)
α → ηin codeDocumentation
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
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 andtangent-accuracy plots for three loading paths (ε₁₁, ε₂₂, ε₁₂)
tests/debug_apex.cpp: standalone diagnostic for the apex-overshootcondition
Bug fixes (also applied to existing code)
rk_plasticity.h:jacobian(m_G, ...)should bejacobian(effective_modulus(m_G), ...)— pre-existing scaling bugbackward_euler::solve(): now reports convergence viaconverged()andclamps result to
≥ 0(no backward plastic flow)small_strain_plasticity::compute(): throws on non-convergence (was silentwrong-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 ofnumeric_limits::min()flow_normal_stress_derivativenow takessig_devdirectly — avoidscancellation error from reconstructing
sfrom non-associativeNevaluate_at_statethreshold scaled toσ_0— prevents subnormal-stressdivisions in downstream
1/J₂termsTest plan
fixes)
bug fixed)