Skip to content

materials: plasticity builds its own elastic tangent - #44

Merged
petlenz merged 3 commits into
mainfrom
refactor/plasticity-owns-tangent
Sep 6, 2026
Merged

materials: plasticity builds its own elastic tangent#44
petlenz merged 3 commits into
mainfrom
refactor/plasticity-owns-tangent

Conversation

@petlenz

@petlenz petlenz commented Sep 5, 2026

Copy link
Copy Markdown
Member

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's tangent. That looked like flexibility and was not: the closed forms require isotropic C_e — it is what makes C_e : N = 2G N and N : C_e : N = 3G true. rk_plasticity assumed it twice, also through effective_modulus(G) = 3G. A caller wiring an anisotropic tangent would have got silently wrong answers.

And harmful

It pulled a linear_elasticity into every plasticity graph, whose own stress output is not merely unused there but wrong — it is C : eps with eps_p ignored, so once yielding starts it is not the stress of anything:

step alpha j2::stress elastic::stress error
40 0.0094 106.25 107.69 +1.4%
120 0.1094 306.25 323.08 +5.5%
200 0.2094 506.25 538.46 +6.4%

A postprocessor logging elastic::stress from a plasticity graph gets that, under a name that reads as authoritative.

All three now build C_e from moduli they hold. j2_plasticity and rk_plasticity gain a K parameter; drucker_prager_plasticity needs nothing new, since K_bulk and G were already required for the cone. A plasticity graph no longer contains an elastic material at all.

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.

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:

old (median) new (median) paired speedup range
J2 1466.2 ns/step 211.5 7.00x 5.54-8.84x
Drucker-Prager 1176.3 ns/step 652.4 1.79x 1.58-1.90x

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.

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 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. 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 differently

Three 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.

@petlenz
petlenz changed the base branch from refactor/dp-dedicated to main September 6, 2026 19:24
@petlenz
petlenz merged commit 0bdbf10 into main Sep 6, 2026
1 check passed
@petlenz
petlenz deleted the refactor/plasticity-owns-tangent branch September 6, 2026 19:25
petlenz added a commit that referenced this pull request Sep 6, 2026
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.
petlenz added a commit that referenced this pull request Sep 6, 2026
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.
petlenz added a commit that referenced this pull request Sep 6, 2026
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.
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