materials: plasticity builds its own elastic tangent - #44
Conversation
Profiling the step showed one rank-4 : rank-2 contraction at ~33 ns against
~245 ns for a whole J2 step, and the return map did two of them: once for the
trial stress, once for the returned stress.
The second is redundant:
sigma = C_e : (eps - eps_p_old - dl N) = sig_trial - dl (C_e : N)
and for isotropic C_e, C_e : N = 2G dev(N) + K tr(N) I -- the same identity the
tangent collapse already uses. J2's flow is deviatoric so tr(N) = 0 and it
reduces to 2G N; Drucker-Prager's is not, so the volumetric term stays.
J2 (same-process A/B): 245.2 -> 195.4 ns/step
DP (interleaved A/B): ~8%, medians 1037 -> 953 ns/step
NOT bit-identical this time, unlike the earlier steps: the result agrees to
about 1 ULP (s00 108.7500531151484 vs ...843) because the arithmetic is
algebraically equal but ordered differently. All 50 tests pass, including the
FD tangent checks on six load paths.
The apex return keeps its contraction: there eps_p comes from
apex_plastic_strain rather than dl*N, so the identity does not apply, and the
branch is rare.
Measurement note: absolute ns figures in this branch's earlier commits were
single runs taken at different times, and this machine's load moves them by
30%+. The J2 comparison above is a same-process A/B; the DP one is interleaved
across alternating runs. Ratios are meaningful, absolutes are not comparable
across commits.
Both plasticity classes read the elastic stiffness from an elastic_source's "tangent" property. That looked like flexibility and was not: the closed forms REQUIRE an isotropic C_e -- it is what makes C_e : N = 2G N and N : C_e : N = 3G true -- so accepting an arbitrary rank-4 tangent advertised a generality neither class can honour. It also dragged a linear_elasticity into every plasticity graph, and that material's own "stress" output is not merely unused there, it is WRONG: it is C : eps with eps_p ignored, so once yielding starts it is not the stress of anything. Measured on a 200-step path it over-predicts by a growing margin: step 40 alpha 0.0094 j2 106.25 elastic 107.69 +1.4% step 120 alpha 0.1094 j2 306.25 elastic 323.08 +5.5% step 200 alpha 0.2094 j2 506.25 elastic 538.46 +6.4% A postprocessor logging elastic::stress from a plasticity graph gets that, under a name that reads as authoritative. Both classes now build C_e from moduli they already hold or now take: j2_plasticity gains a "K" parameter; drucker_prager_plasticity needs nothing new, since K_bulk and G were already required for the cone. elastic_source is gone from both, and a plasticity graph no longer contains an elastic material at all. J2: 195.4 -> 168.1 ns/step DP: ~720 -> ~608 ns/step Values agree with the pre-refactor implementation to about 1 ULP on alpha, stress and tangent. linear_elasticity is untouched and keeps its users: for a genuinely elastic model C : eps IS the answer, and isotropic_damage consumes both its stress and its tangent legitimately. rk_plasticity still takes an elastic_source; it is the remaining templated class and is left alone here. An earlier draft of this reasoning proposed dead-property elimination in the property engine, with a declared-outputs mechanism to tell an unread property from one a host reads through ctx.get(). That was solving the symptom. The property should not be in the graph.
Same change as the other two plasticity classes, for the same reason. rk_plasticity read the stiffness from an elastic_source while assuming isotropy twice over: effective_modulus(G) = 3G in its stage residuals, and compute_tangent's C_e : X = 2G X collapse. An arbitrary rank-4 tangent could not have been honoured by either. Takes "K" and builds C_e in the constructor; elastic_source is gone. Bit- identical over a 40-step SDIRK3 path, all 17 digits: before: 0.01062302967272258 108.7500531151484 250.00062497968736 76.554019397938148 after : 0.01062302967272258 108.7500531151484 250.00062497968736 76.554019397938148 That comparison matters here because the suite's rk-vs-j2 equivalence test now has both sides changed; this one holds rk against its own pre-change output. SDIRK3TangentCheck, which compares the tangent against a numerical derivative, is the independent check and passes unchanged. No plasticity material takes an elastic_source now. The one remaining consumer is isotropic_damage, which reads elastic::stress legitimately -- damage scales an elastic stress, and with no plastic strain in that model C : eps IS the stress.
petlenz
left a comment
There was a problem hiding this comment.
Critical review. The argument holds — I re-ran the divergence measurement and the elastic stress really does drift to +6.4 % — but the change introduces a naming collision, and the two materials it touches disagree with each other.
K now means three things in one document
Before this PR, j2_plasticity took no bulk modulus: it read the tangent from an elastic material. It takes one now, and lands next to a hardening material that already uses that letter for something else:
{"type": "linear_isotropic_hardening", "name": "hardening", "K": 1000.0} <- hardening modulus, H = K k
{"type": "j2_plasticity", "name": "j2", "K": 166.67} <- BULK modulus
{"type": "drucker_prager_plasticity", "name": "dp", "K_bulk": 166.67} <- bulk, spelled differentlyThree parameters called K or K_bulk, two distinct physical quantities, and the two plasticity materials this stack created spell the same quantity differently. A deck author reading "K": 1000.0 next to "K": 166.67 has no way to see that one is a hardening slope and the other a bulk modulus.
That is squarely the failure mode this stack has been removing elsewhere: a value that is wrong-but-plausible and reads as authoritative. Transposing those two numbers gives a model that builds, runs, and yields — at the wrong stress.
j2_plasticity and j2_rk_plasticity should take K_bulk, matching Drucker-Prager. Two-line change each plus the test fixtures, and it makes the collision impossible rather than merely documented.
(linear_isotropic_hardening's K is pre-existing and arguably wants to be H or hardening_modulus, but renaming it touches an unrelated material and its tests — worth its own issue rather than this PR.)
The correctness argument re-verified
step 40 alpha 0.0094 j2 106.25 elastic 107.69 +1.4%
step 120 alpha 0.1094 j2 306.25 elastic 323.08 +5.5%
step 200 alpha 0.2094 j2 506.25 elastic 538.46 +6.4%
And the isotropy claim checks out: effective_modulus(G) = 3G already required it, so this makes an existing assumption explicit rather than adding one. The three-commit split is right — the ~1 ULP step is isolated so a reviewer can see exactly which change costs exactness.
Related, from the #40 review
The same document that can now set K and G still cannot choose the integrator: the solver type is compile-time fixed, and j2_rk_plasticity's tableau is a raw pointer the JSON reader cannot convert. Detail on #40.
The plasticity chain landed on main while this branch was open, and it moved three things this branch depends on. backward_euler.h conflicted. Both sides were fixing the same defect -- a std::max(x, 0) clamp buried inside a general scalar Newton, which turned a diverged solve into a plausible-looking non-negative answer. This branch split the method into solve() and solve_nonnegative(); main (#40) deleted the callback mode from backward_euler entirely, moving it to local_newton over newton_scalar, which never clamps and returns convergence WITH the value rather than by a separate accessor. main's version is kept whole. Nothing is lost: the non-negativity is a KKT statement and now lives at the three plasticity call sites, where #46 further replaced clamping with a throw for the ill-posed softening case. drucker_prager_plasticity.h did NOT conflict, which is the dangerous part. Git followed the small_strain_plasticity.h -> drucker_prager_plasticity.h rename and silently applied this branch's edit to the renamed file: - return m_solver.get().solve(eval); + return m_solver.get().solve_nonnegative(eval); That would have reintroduced clamping inside the solve -- undoing #46 -- and it was reported as a clean auto-merge. main's version is kept verbatim. Three of this branch's tests in test_materials.cpp exercised the deleted callback API. Two are superseded: NonnegativeVariantClampsConvergedRoot tests a method that no longer exists, and FailurePathIsNotClamped is covered by NewtonScalar.ABudgetExhaustedShortOfTheRootReportsFailure. The third asserted something main does NOT cover -- that a negative root survives the solver -- so it is ported to NewtonScalar.ANegativeRootSurvives rather than dropped. The umat fixtures needed three adaptations to the split plasticity API: - small_strain_plasticity.h supplied a j2_plasticity ALIAS; it is a real class now, so the five umat tests include j2_plasticity.h. They were not unused includes, as they first appeared -- the build caught that. - The fixtures built a backward_euler as the plasticity solver, relying on the callback mode #40 removed. That role is local_newton. The one solver that drives curing is genuinely graph-driven and stays backward_euler. - j2_plasticity builds its own elastic tangent from K and G (#44) and has no elastic_source, so the plasticity blocks pass K. 207/207 tests pass. Against main this branch is now purely additive: 7580 insertions, no deletions. Left alone deliberately: the fixtures still create a linear_elasticity that plasticity no longer consumes -- harmless, and removing it would move the statev layout these tests assert -- and ExternalScalarSource uses temp = 353.0 under the same degrees-C/K confusion fixed on main, which makes both sides of that comparison saturate instantly. Neither belongs in a merge resolution.
main did not build. tests/test_tangent_generator.cpp, added by #28, includes materials/small_strain_plasticity.h, which #43 renamed away. The test it belonged to asserted that isotropic_tangent is a drop-in for linear_elasticity as a plasticity "elastic_source". Plasticity no longer has an elastic_source: #44 removed it because the closed-form stress and tangent require an isotropic C_e, so accepting an arbitrary rank-4 tangent advertised a generality the material cannot honour. The premise is gone. The drop-in claim is still covered against consumers that do source a tangent: TangentSource.* in the same file, and weighted_sum's tangent_sources tests in #29. The other nine tests in this file exercise isotropic_tangent directly and are untouched. How this reached main: the PR was retargeted from its stacked base to main, and retargeting did not trigger a fresh CI run. The green check I read predated the retarget, so it had been tested against the old base where the header still existed. Git reported no conflict either -- nothing edited the same lines. From here every merge is verified by building the merge result locally, not by trusting a check-run whose base may be stale. 217/217 tests pass.
Drucker-Prager was held out of the factory with a stated condition: register it once the yield function is expressible from a document. #43 met that condition by making eta, beta and K_bulk plain required scalars instead of members of a C++ yield_function object the JSON reader could not convert. Registered now, and the test that pinned its absence is replaced by two that pin the reason the absence was needed: - a complete Drucker-Prager document builds and yields - a document missing "eta" throws and leaves no material behind, rather than silently getting eta = beta = k = 0 and running as elasticity local_newton is registered too. Without it #33 would still not be closed: every return map names its solver through "solver_source", so a deck could name j2_plasticity but not the solver it requires, and the model still could not be built from a document. The J2 document in this file also needed updating -- it named backward_euler as the plasticity solver, which is the callback mode #40 removed, and passed elastic_source, which #44 removed when plasticity took ownership of its own elastic tangent. One honest limitation recorded in the test rather than papered over: the exception for a missing parameter carries only a COUNT, "missing 1 required parameter(s)". numsim-core's input_parameter_controller prints the names to stdout and throws the count separately, so a deck typo is loud but not self-explanatory. Fixing that is a numsim-core change; what this test pins is that the cone cannot be built without its parameters. 258/258 tests pass on the merge result, verified locally.
4 of 5. Stacked on the Drucker-Prager collapse. Three commits, two arguments.
The elastic dependency was dishonest
All three plasticity classes read the stiffness from an
elastic_source'stangent. That looked like flexibility and was not: the closed forms require isotropicC_e— it is what makesC_e : N = 2G NandN : C_e : N = 3Gtrue.rk_plasticityassumed it twice, also througheffective_modulus(G) = 3G. A caller wiring an anisotropic tangent would have got silently wrong answers.And harmful
It pulled a
linear_elasticityinto every plasticity graph, whose ownstressoutput is not merely unused there but wrong — it isC : epswitheps_pignored, so once yielding starts it is not the stress of anything:A postprocessor logging
elastic::stressfrom a plasticity graph gets that, under a name that reads as authoritative.All three now build
C_efrom moduli they hold.j2_plasticityandrk_plasticitygain aKparameter;drucker_prager_plasticityneeds nothing new, sinceK_bulkandGwere already required for the cone. A plasticity graph no longer contains an elastic material at all.linear_elasticityis untouched and keeps its users — for a genuinely elastic modelC : epsis the answer, andisotropic_damageconsumes both its stress and its tangent legitimately.Also: the redundant stress contraction
The return map contracted twice — trial stress, then returned stress. The second is
sig_trial - dl (C_e : N), no contraction needed. This is the one step that is not bit-identical: algebraically equal but ordered differently, so it agrees to ~1 ULP. Kept as its own commit for that reason.Performance, for the whole 4-PR chain
Two binaries built from the actual commits, run interleaved so each pair sees the same machine state; 15 pairs; speedup computed per pair:
The range is the honest figure — the same binary measured 1171-2253 ns across runs, so any single-run comparison on this machine is worth about one significant digit.
J2 gains more because it also sheds machinery it never used. DP keeps the non-associative structure and the apex branch and gains only the arithmetic, which is the correct outcome: the generality DP pays for is generality DP uses.
+80/-41 across 7 files.