Skip to content

Add vector_newton: coupled Newton for mixed scalar/tensor local systems - #17

Open
petlenz wants to merge 15 commits into
feature/drucker-pragerfrom
feature/vector-solver
Open

Add vector_newton: coupled Newton for mixed scalar/tensor local systems#17
petlenz wants to merge 15 commits into
feature/drucker-pragerfrom
feature/vector-solver

Conversation

@petlenz

@petlenz petlenz commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

Adds a coupled Newton solver for R(x) = 0 over a mixed scalar/tensor unknown set — the vector analogue of backward_euler::solve — plus the Mandel serialization layer it needs.

Closes #13. Implements #14, #12, and the solver half of #15.

Base: this targets feature/drucker-prager (PR #10), which is its actual base, so the diff stays at three commits instead of dragging in all of #10.

Depends on #16 (fetch tmech): unknown_layout.h uses the Mandel adaptor tag from tmech#30, so this will not configure for anyone whose system tmech predates it.

Commits

8e84f13 #13backward_euler::solve() becomes a general scalar Newton; opt-in solve_nonnegative()
7a1c7be #15 — Mandel unknown_layout: tensor ↔ flat serialization
fcf8756 #14, #12vector_newton + JSON config

Design

Per-property layouts behind a type-erased base. Every piece of the local system — each unknown, each residual entry, each Jacobian block — carries its own layout. The solver holds a vector of layouts for the residual and a matrix for the Jacobian, and knows only layout_base. Consequences:

  • Every block is a real typed property, so input_property<T>::wire()'s dynamic_cast type-checks each one; a rank mismatch fails to wire rather than corrupting the system.
  • Absent blocks are structurally zero for free — no property, no packing.
  • The solver needs no N template parameter, so it registers as a single factory type; the layout is dispatched from the "unknowns" parameter rather than baked into the template.

Symmetry must be declared, not inferred. tmech::tensor<T,Dim,2> is symmetry-agnostic storage (even inv() takes a symmetry sequence at the call site), and property_traits carries no shape metadata. Hence the explicit kind tags, with the runtime counterparts in core/unknown_spec.h so the JSON layer depends on neither the solver nor tmech.

Block layouts are parameterized on the kind pair, not the value type. Blocks (scalar, sym) and (sym, scalar) are both tensor<T,3,2> but pack as a 1×6 row and a 6×1 column respectively — the value type cannot disambiguate.

Mandel, not Voigt. A:B == mandel(A)·mandel(B) and (D:X) == mandel4(D)·mandel2(X), so the flat LU solve and the ∞-norm convergence test are equivalent to the tensor system. Applying the same weights uniformly to residual, unknown, and both sides of every block is what makes this work.

Iteration

Evaluate-first, so the residual after the final update is the one tested — a solve converging on its last allowed update is not falsely reported failed. Residual ∞-norm convergence, partialPivLu plus a backward-error guard against a singular Jacobian (cheaper than rank-revealing pivoting every iteration, and finiteness alone would not catch it), honest converged(), and no hidden projection — which is what #13 was about.

JSON

{"type": "vector_newton", "name": "solver",
 "function": "kin",
 "unknowns": [{"name": "dgamma", "kind": "scalar"},
              {"name": "backstress", "kind": "sym_tensor"}],
 "zero_blocks": [["dgamma", "backstress"]]}

No "dim" — the dimension is fixed by the Traits policy, which keeps the kind set small enough for an exhaustive switch. Blocks are wired dense by default so an omitted block is a wiring error, not a silently-wrong Jacobian; sparsity is opt-in.

Testing

67/67 pass (was 49). test_j2_plasticity and test_drucker_prager are the regression signal that de-clamping solve() changed nothing for existing callers.

Three things the tests corrected during development:

  1. A rank-deficient but consistent system is not a failure case — converging onto the solution manifold is right. The guard test now uses rank-deficient and inconsistent.
  2. Mandel rank-4 requires minor symmetry (36 of 81 components). Documented as a precondition; satisfied by construction when both unknowns are symmetric. Major symmetry is not required, and there is a test on a minor-symmetric/major-asymmetric block — the non-associative (C:N)⊗(M:C) shape — confirming it lands untransposed at non-zero offset and stride.
  3. Topology follows backward_euler: the solver produces the unknowns and consumes residual/Jacobian, so the function material reads the trial state back over Local edges.

Not included

The physics integration tests from #14/#15 — two-surface plasticity and J2 with tensor back-stress — are new constitutive models rather than tests, and are left as follow-ups. solve_with_factorization() lands as a hook for #12's consistent tangent, but the tangent-block wiring waits for a consumer; it holds a stale factorization if the solve did not converge, so callers must check converged().

petlenz added 4 commits July 27, 2026 22:31
The FetchContent fallback for tmech already existed but could never fire:
find_package(tmech QUIET) succeeds against ANY installed version, so a stale
system install always won. tmech's package config exposes no version
constraint that could express "new enough", so there is no way to make
find_package reject one — inverting the priority is the only fix.

Had the fallback ever fired it would also have misbehaved: it set BUILD_TESTS,
which is numsim-core's option, not tmech's. tmech uses TMECH_BUILD_TESTS and
TMECH_BUILD_EXAMPLES, both defaulting ON, so fetching would have built tmech's
entire gtest suite and examples into this project's build tree.

Adds NUMSIM_USE_SYSTEM_TMECH (default OFF) to opt back into an installed copy
via find_package(tmech REQUIRED). For co-developing the two repositories,
-DFETCHCONTENT_SOURCE_DIR_TMECH=/path/to/tmech points at a local checkout —
the same escape hatch already documented for numsim-core. GIT_TAG master
matches the existing numsim-core convention.

Verified: a clean configure fetches tmech at acbe92f, builds, and leaks no
tmech tests into ctest.
…ms (Refs #14, #12, #15)

The vector analogue of backward_euler::solve — a dense pivoted Newton for
R(x)=0 over a heterogeneous unknown set. Unblocks local return maps that are
coupled systems: multi-surface plasticity, kinematic hardening with a tensor
back-stress, viscoplastic couplings.

- solvers/vector_newton.h: evaluate-first iteration, so a solve that converges
  on its last allowed update is not falsely reported failed. Residual inf-norm
  convergence, partialPivLu with a backward-error guard against a singular
  Jacobian, honest converged(), and no hidden projection. Covers both the
  direct solve() and the graph-driven update() mode (#12), plus
  solve_with_factorization() so a consistent tangent can reuse the converged
  LU without forming an inverse.
- core/unknown_spec.h: the runtime unknown description, kept out of solvers/
  so the JSON layer depends on neither the solver nor tmech.
- JSON: unknown_spec and block_ref readers modelled on the existing sop
  converter. vector_newton registers as a single factory type — the layout is
  dispatched from the "unknowns" parameter rather than baked into the template,
  so there is no instantiation per unknown combination.

Topology follows backward_euler: the solver produces the unknowns and consumes
the residual and Jacobian pieces, so the function material reads the trial
state back over Local edges. Jacobian blocks are wired dense by default, making
an omitted block a wiring error rather than a silently-wrong Jacobian;
"zero_blocks" makes sparsity opt-in.

Rank-4 blocks must be minor-symmetric, since Mandel stores 36 of 81 components.
Major symmetry is not required, so a non-associative algorithmic tangent packs
correctly — there is a test on a minor-symmetric, major-asymmetric block.
@petlenz
petlenz force-pushed the feature/vector-solver branch from fcf8756 to 02b6243 Compare July 28, 2026 21:26
petlenz and others added 5 commits July 28, 2026 23:36
Fetch tmech instead of finding it; fix the fallback's option names
…luation

Review fixes for the coupled Newton solver.

Name pre-check, before any property is created. This has to run first because
the property registry cannot report the problem afterwards: add_property()
silently returns the EXISTING property when a name is taken and downcasts it to
the requested type. A duplicate unknown name would therefore alias two unknowns
onto one storage slot while N still counted them separately, and a duplicate
whose kind differed would reinterpret a property<double> as a property<tensor2>
— undefined behaviour, with no diagnostic anywhere. check_names() now rejects
empty lists, empty names, duplicates, and 'zero_blocks' entries naming an
undeclared unknown (a typo there would silently drop a real block from the
Jacobian).

It also rejects colliding generated names rather than banning underscores:
unknowns {a, b_c, a_b, c} produce "jacobian_a_b_c" from both (a, b_c) and
(a_b, c), so the check requires every generated property name to come out
distinct while leaving "back_stress" perfectly usable.

gather_jacobian() no longer calls update_source(). The material computes
residual and Jacobian in one callback and gather_residual() has just run it at
this iterate, so re-triggering per block re-ran compute N^2 times per iteration
for no change in the result.

solve_with_factorization() now throws unless the last solve converged; it would
otherwise hand back a factorization from an earlier iterate, or from an earlier
call entirely, as though it were the converged tangent.

Tests: name validation, factorization guard, a mistyped producer property
failing to wire (previously claimed as a design benefit but never tested), and
the mixed scalar/tensor system at Dim 2 — the width-3 sym_tensor path had no
coverage at all. 77 tests pass, up from 67.

Documents that a failed solve leaves the raw iterate in the output properties.
That matches backward_euler::solve, which also returns its raw iterate on the
failure paths, but the values here sit in graph properties that downstream
materials read regardless of converged().
…obian block

Regression from the previous commit, which narrowed update_source() to the
residual inputs on the grounds that the material computes residual and Jacobian
in one callback.

update_source() fires the callback of the SPECIFIC property it is called on. A
material that binds its compute to a Jacobian block therefore never got
re-evaluated at all: the solver gathered default-constructed properties, saw
R == 0, and reported convergence at the initial iterate. Not a slow or failed
solve — a confident wrong answer, which is the exact failure class the rest of
this solver is built to avoid.

Restores the update over every input, but hoisted into refresh_sources() and
called once per iteration rather than once per gather. Inputs whose source has
no callback cost an empty std::function check, so in the normal case compute
still runs exactly once per iteration.

Adds the regression test that caught it: a material binding compute to
jacobian_x_x must solve identically to one binding it to residual_x. Every
existing test material happened to bind to a residual property, which is why
the suite stayed green through the regression.
The Newton iteration tolerates an inexact Jacobian — the root is pinned by
R == 0, so a wrong J costs iterations, not accuracy. The linearization does
not. The consistent tangent comes from the implicit function theorem,

    J * dx/deps = -dR/deps,   J = dR/dx at the converged point

which inverts J as the actual derivative. Zeroing out a block that is not
actually zero therefore yields a silently wrong dx/deps, hence a wrong
dsigma/deps and the loss of quadratic convergence in the global Newton.
solve_with_factorization() hands back exactly that J, so it inherits the
constraint.

The previous test made this worse by endorsing the unsound usage: it declared
dR_x/dy zero for a system where that entry is 1, called it "a lie", and
concluded that an omitted block "changes the path, not the answer" — true of
the root, false of the tangent.

Replaces it with a genuinely decoupled system (dR_x/dy identically zero), where
the omission is exact, and asserts that the reused factorization reproduces the
true J^-1 column — so the test now pins the property that actually matters.
Documents the constraint at the block wiring and on
solve_with_factorization(), and notes there that a multi-column right-hand side
is accepted, so the whole N x 6 strain derivative solves in one call.
@petlenz

petlenz commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Newton convergence on the test systems

Measured through the solver itself rather than a reimplementation: run with max_iter capped at k, then read sys::residual_* back out of the graph. For the mixed system the norm is taken over the Mandel-packed residual, i.e. exactly the quantity solve() tests.

Nonlinear 2×2 — x² + y = 3, x + y² = 5, root (1, 2), from (0, 0)

k ‖R‖∞ x y r/r_prev²
0 5.0000e+00 +0.0000000000 +0.0000000000
1 2.5000e+01 +5.0000000000 +3.0000000000 1.000
2 5.7113e+00 +2.6101694915 +1.8983050847 0.009
3 1.1831e+00 +1.5224833506 +1.8651055884 0.036
4 1.8142e-01 +1.0965508740 +1.9789946553 0.130
5 8.4280e-03 +1.0047466324 +1.9989122235 0.256
6 2.2411e-05 +1.0000126379 +1.9999971348 0.316
7 1.5971e-10 +1.0000000001 +2.0000000000 0.318
8 0.0000e+00 +1.0000000000 +2.0000000000

Asymptotically r/r_prev² settles at ≈ 0.318 — textbook quadratic convergence.

Note the first step: the residual rises 5 → 25. At (0, 0) the Jacobian is [[0,1],[1,0]] — nonsingular, but a poor linearization point, so the full Newton step overshoots to x = 5. It recovers, but this is precisely the behaviour that a line search would damp, and is worth keeping in mind against the "no damping" item in #21.

Mixed, 1 scalar + 1 symmetric tensor (N = 7) — g + tr(B) = 2, B = g·P

k ‖R‖∞ g
0 2.0000e+00 +0.000000000000
1 2.2204e-16 +0.645161290323

One update to machine precision — because this system is linear in (g, B). Worth stating plainly: the committed MixedScalarAndSymmetricTensorSystem test validates the plumbing (block assembly, Mandel packing, the N×N solve, unpacking) but does not exercise iterative convergence at all.

Mixed, genuinely nonlinear (N = 7) — B = g·P − 0.1·g²·I

k ‖R‖∞ g r/r_prev²
0 2.0000e+00 +0.000000000000
1 4.1623e-02 +0.645161290323 0.010
2 2.1186e-04 +0.691189444210 0.122
3 5.6022e-09 +0.691426133042 0.125
4 2.2204e-16 +0.691426139301

r/r_prev² settles at ≈ 0.125. Quadratic convergence is preserved with a tensor unknown in the system, which is the substantive result here: it confirms the Mandel packing keeps the flat system equivalent to the tensor one, exactly as the isometry argument predicts. A Voigt packing would not preserve the ∞-norm this is measured in.

Takeaways

  1. Quadratic convergence holds for scalar-only and for mixed scalar/tensor systems alike.
  2. The committed N = 7 test is linear, so it converges in one step and never exercises the loop. Adding the nonlinear variant above as a test would close that gap cheaply.
  3. Undamped Newton can increase the residual substantially on the first step from a poor initial guess (5 → 25 here). Relevant to vector_newton: robustness and coverage follow-ups from review #21.

petlenz added 6 commits July 29, 2026 19:10
The existing mixed test is linear in (g, B), so Newton reaches machine
precision in a single update. It pins the plumbing — block assembly, Mandel
packing, the NxN solve, unpacking — but never runs the iteration for a system
containing a tensor unknown, so a regression in the loop would not be caught
there.

Adds a quadratic term, R_B = B - g*P + c*g^2*I, behind a 'c' parameter that
defaults to 0 so the existing linear tests are unchanged. Measured convergence
from the zero iterate:

    2.00e+00 -> 4.16e-02 -> 2.12e-04 -> 5.60e-09 -> 2.22e-16

with r/r_prev^2 settling around 0.125 — quadratic, with a tensor unknown in the
system. That is the property the Mandel packing has to preserve: the
convergence test is an infinity-norm over the packed vector, so a
non-isometric packing would not keep it equivalent to the tensor system.

The test asserts the closed-form root of D*c*g^2 - (1 + tr P)*g + a = 0, the
matching B, and — so it cannot quietly go vacuous — that a single update does
NOT converge.
…Refs #19)

Differentiating the converged residual R(x(eps), eps) = 0 gives

    J * dx/deps = -dR/deps,   J = dR/dx at the converged point

which is solved from the factorization already computed there — no inverse is
formed, and the whole N x W system is one multi-column back-substitution. The
material completes the chain rule itself:

    dsigma/deps = dsigma/deps|_x + (dsigma/dx) : (dx/deps)

Opt-in through a 'strain_derivative' parameter, default off, since it costs a
gather and a solve that a caller wanting only the root does not need. When on,
the solver consumes d_residual_<name>_d_strain and produces d_<name>_d_strain.

dR_I/deps has the shape of a Jacobian block whose column kind is the strain, so
jacobian_block_layout already serializes it and no new packing was needed. What
was missing is the scatter direction: dx/deps comes back as an N x W matrix and
has to become per-unknown tensors of rank r_I + 2 again. block_scatter_base and
strain_derivative_layout add that, mirroring the row-major/column-major
transposition that jacobian_block_layout owns on the way out.

Verified against a closed form rather than only a round trip. For
R_g = g + tr(B) - a - tr(eps), R_B = B - g*P the solution is
g = (a + tr eps)/(1 + trP), hence dg/deps = I/(1+trP) and
dB/deps = (P (x) I)/(1+trP) — matched to 1e-12. The case is chosen so the
scalar row of dR/deps is nonzero while the tensor block is zero, and so
dB/deps is minor-symmetric but major-ASYMMETRIC, with an assertion that the
reference really is asymmetric so the transpose check cannot go vacuous.
Layout-level tests cover scatter inverting gather at non-zero offset and
stride for both the 1 x W row and W x W block shapes.

Also registers bool with the JSON reader registry, which had no reader for it.
Library: the strided placement in jacobian_block_layout::gather and
strain_derivative_layout::scatter_block was hand-written index arithmetic —
precisely where a row-major/column-major slip hides. It is now expressed as
Map-to-Map assignments, so Eigen performs the storage-order conversion. The
Rx1 column case drops its scratch buffer entirely: those destination slots are
contiguous, so tmech packs straight into them.

Tests: nested component loops replaced by tmech::almost_equal for tensor
comparisons, and by Eigen Map expressions (dot, matrix-vector, isApprox) for
flat buffers. The dimension-templated test tensor is a literal per dimension
instead of an index-computed fill; only tr(P) enters the closed forms, which the
new literals preserve.

One trap found while doing this, worth recording. A single row of a
column-major buffer must be viewed as a vector with INNER stride ld, not as a
fixed-size Matrix<T,1,C> with outer stride: Eigen treats Matrix<T,1,C> as a
vector at compile time, linearizes it with inner stride 1, and silently ignores
the outer stride — writing the row contiguously instead of across the buffer.
The first version of this refactor did exactly that, and the layout unit test
did not catch it because it read the row back through the same faulty strided
view, so both sides were wrong consistently while the 3D mixed solver tests
failed. The placement tests now read through a plain dense Map of the whole
buffer, with no custom stride and no fixed-size vector type, so they cannot be
self-consistent with a broken writer.
A UMAT host inverts every ownership the framework assumes. Strain comes from
STRAN/DSTRAN instead of a stepper, history from STATEV instead of
history_property, and the host decides when an increment has converged — while
re-calling the material on iterates it later discards. The layer added here
makes a finalized material_context behave as a stateless material-point
evaluator: raw pointers in, raw pointers out, nothing retained between calls.

That statelessness is the design, not an optimisation. Reloading every history
variable from STATEV at the top of each call is what makes a repeated call on
an unconverged global iterate give the same answer, and it is why one context
per thread suffices rather than one per integration point. It also makes the
plane-stress solve a one-liner: each trial out-of-plane strain has to integrate
from the same converged state at t_n, which is just another unpack.

No existing material is modified. An earlier draft converted time to a plain
property so the STATEV wrapper would not pick it up; that would have broken
autocatalytic_reaction and curing_rate, which legitimately need the old/new
pair. Host-supplied strain and time stay history properties — external
sources write BOTH sides directly — and the wrapper excludes them by an
explicit (material, property) list instead. Every exclusion must match a real
history property or construction throws: an unmatched entry would leave
host-owned state in STATEV and shift every following slot.

Layers, each usable without the one above it:

  tensor_conversion   host pointer <-> canonical 6-slot buffer <-> tmech
  external_state_source  host-driven "strain"/"time", bind() sets old and new
  statev_map          history <-> flat STATEV, with the exclusion list
  material_point_evaluator  unpack -> bind -> update -> read -> pack
  plane_stress_evaluator    outer eps_33 Newton + static condensation
  umat_interface      registry, per-thread contexts, the Fortran shim

Every element family runs the same Dim=3 material; 2D is handled by widening
the host's NTENS slots into the canonical buffer, not by a second policy. A
genuinely 2D material would need a separate implementation per model, and
plane stress needs a local solve regardless.

Traps found, all now covered by tests that fail without the fix:

tmech already ships the Abaqus orderings (abq_std, abq_exp), so the conversion
is reuse rather than hand-rolled index tables. Three conventions cross there
and only their combination is right: strain carries engineering shear, stress
does not, and the rank-4 tangent takes no scaling at all because the factor 2
is already absorbed by the minor-symmetry sum. The tangent is packed row-major
by tmech but DDSDDE is a Fortran array, so narrow_matrix transposes — invisible
for a major-symmetric tangent, which is why the tests use a deliberately
asymmetric one and a second test guards that the fixture stays asymmetric.

Plane stress is not a prefix of the 3D slot map: its components are 11, 22, 12,
so host slot 2 is the shear, not the out-of-plane normal. Assuming a prefix
still yields a plausible-looking 3-vector.

STATEV layout is sorted by (owner, property) rather than following the graph's
execution order, which comes from a topological sort seeded by unordered_map
iteration and is not stable across builds. A layout that moved between builds
would silently reinterpret every existing restart file.

Failures are classified rather than uniformly retried. fatal_error zeroes the
outputs and terminates through XIT; anything else asks for a smaller increment
via PNEWDT, which is the right default for exceptions escaping the constitutive
models themselves. Getting this backwards is worse than not classifying: an
unknown CMNAME originally returned normally with DDSDDE untouched, so Abaqus
solved on with whatever the buffer happened to contain, and a misconfigured
model requested a cutback forever against a fault no increment size could fix.

Under NLGEOM=YES Abaqus rotates STRESS and STRAN but never touches user state,
so statev_map rotates tensor-valued history itself. Skipping it leaves plastic
strain in a stale frame and the error accumulates silently over the rotation
history. Note tmech::dot is first-order only; the rank-2 rotation uses
operator*, which contracts the last index of the left with the first of the
right.

The call struct carries spans, not bare pointers. Array lengths are dictated by
the element family, and passing a 4-element array with the default solid3d case
reads six — a mistake made while writing these tests, which surfaced only as a
wrong value. It is now rejected.

Verification beyond unit tests: DDSDDE finite-differenced through the raw
pointer boundary in both the elastic and plastic regimes, which is what catches
a shear factor, a slot permutation or a transpose; statelessness shown by
rebuilding a fresh context every step and matching a persistent reference
bit-for-bit through 25 plastic steps; plane stress audited by replaying the
recovered eps_33 through an independent 3D evaluator and confirming sigma_33
vanishes; the eight-thread concurrency test run under ThreadSanitizer.

Not covered: model configuration is a builder function, so wiring CMNAME to a
JSON config file is still unbuilt. SSE and SPD are reported only when the model
names its plastic strain, since splitting work into stored and dissipated parts
is not possible without the elastic strain; when unset the host's energies are
left alone rather than filled with a guess. RPL, DDSDDT, PREDEF and the
deformation gradients are accepted and ignored.
…STATEV convention

Follow-up to c9ea9ed, from a four-way review (Abaqus contract conformance,
C++ safety, numerics, mutation testing). The contract items all came back
conformant — argument order, component orderings, column-major DDSDDE, TIME(2),
PNEWDT/XIT, the Fortran hidden string length — and the numerics verified against
independent computations: the condensed plane-stress tangent matches finite
differences of the full outer solve to 2.9e-6, the engineering-shear packing to
2.8e-17, and DROT is worth 15% error over 20 plastic steps. What follows is
everything that did not come back clean.

Setup faults are now classified at the boundary, not at each throw site.
thread_state_for() runs the registered builder — arbitrary graph construction
this layer does not own — and the core library reports a mistyped source name or
an unknown material type as a plain std::runtime_error. Those reached the
generic handler and asked for a cutback, against a fault no increment size can
fix, rebuilt and re-failed on every call from every thread. This was the third
instance of the same bug (c9ea9ed fixed two); classifying per throw site cannot
work when the throwing code belongs to another layer, so construction is now
wrapped whole and every failure inside it is fatal.

DDSDDE is zeroed on the cutback path too, not only the fatal one. PNEWDT is the
minimum over all calls for the iteration, so Abaqus finishes the element loop
and assembles BEFORE acting on the request — previously with whatever the buffer
happened to contain. A zero tangent is a poor stiffness and that is the accepted
trade: the increment is discarded either way, and predictably soft beats
arbitrarily wrong.

umat_dispatch can no longer terminate instead of reporting. Two allocations
escaped its handlers: the CMNAME string was built above the try, and the fatal
message was concatenated inside a catch — where an exception is not caught by
the sibling catch(...) and so escaped a noexcept function *before* the fatal
handler ran, losing exactly the diagnostic that path exists to print.

SSE and SPD are both accumulated incrementally, as Abaqus's own materials do.
SSE was absolute (1/2 sigma:(eps - eps_p)) while SPD was derived from its
change, which is only self-consistent if the host's incoming SSE is the stored
energy at t_n — and Abaqus initialises SSE to zero whatever the initial state.
Under *INITIAL CONDITIONS, TYPE=STRESS the whole pre-existing stored energy was
booked once as NEGATIVE plastic dissipation (measured: SPD = -1.35 on a step
with no plastic flow) and the offset persisted in ALLPD for the analysis.

Rank-2 state variables are stored in STATEV with engineering shear. The library
round-trips either convention exactly, so this was invisible internally and
wrong only where it matters: against STRAN, against Abaqus's PE/LE output, and
against an initial plastic state seeded through *INITIAL CONDITIONS,
TYPE=SOLUTION. describe() now names the convention. Note this assumes tensor
history is strain-like, which holds for everything the framework carries; a
back stress would want the unscaled form and a per-property decision.

Also: CMNAME is folded to a common case on both registration and lookup, since
Abaqus input is case-insensitive and delivers the name upper-cased; the fatal
handler is atomic (TSan-confirmed race); and plane stress rejects a DROT that
tilts out of the plane, which the scalar out-of-plane strain in STATEV cannot
represent.

Tests: 159 -> 176. Mutation testing found 10 defects that no test caught, all
now closed and each re-verified by re-running its mutation. Three are worth
recording because the tests looked convincing and were not:

The energy balance was a tautology. SSE and SPD are updated as
`sse += dSSE; spd += dW - dSSE`, so their sum telescopes to the accumulated work
for ANY definition of dSSE — defining the elastic strain as the total strain
passed it. The split is now pinned by an absolute SSE on an elastic step and by
zero dissipation while elastic, in both the 3D and plane-stress paths.

The objectivity test used u::rotate as its own oracle: it rotated the inputs and
computed the expectation with the function under test, so R a R^T -> R^T a R
cancelled and passed. Compounding it, a 90-degree rotation of a diagonal tensor
cannot tell the two senses apart, and an invariant check passes for any
orthogonal map. There is now a 30-degree fixture with a shear component whose
expectation comes from the closed-form rotation algebra.

The STATEV encoding was unconstrained: every test round-tripped through the same
pack/unpack pair, so swapping the shear convention or permuting two slots passed
them all. The raw slot values are now asserted directly.

Smaller ones: the whole rank-4 branch of statev_map was unreachable from tests
(no production material has rank-4 history — there is now a test-only material
that does); TIME(1) and TIME(2) were indistinguishable because the fixture
passed identical values in both slots and no model registered at the ABI used a
time source; the warm-start test passed against an implementation that never
warm-starts, since both runs were then cold and EXPECT_LE held; and the
NTENS-vs-NDI/NSHR guard was dead code, though removing it would have built
6-element spans over a 4-element host array.

One comment was actively wrong and is corrected: the finite-difference tangent
check claimed to catch a row/column-major transpose. It does not — J2's
consistent tangent is major-symmetric, so the transpose is invisible there. That
contract rests entirely on the deliberately major-asymmetric fixture in
test_umat_conversion, which makes it a single point of coverage worth keeping.

Verified: 176/176 in Debug and Release (NDEBUG), 77 tests under ASan+UBSan, the
ABI and threading tests under ThreadSanitizer.
The remaining findings from the four-way review — documentation, plus one
performance item that turned out to be in the hottest path there is.

The macro's "accepted but not used" note listed four arguments and ignored
twelve. The omission that matters is PROPS/NPROPS: this shim takes material
constants from the registered builder, not from *USER MATERIAL CONSTANTS, which
is the single most surprising fact about the design and was written down
nowhere. A deck that varies PROPS while naming the same model silently gets the
builder's constants for all of them. The full list is now enumerated with the
conditions under which ignoring each stops being safe — RPL and friends in
coupled thermal-stress, thermal-electrical-structural and adiabatic procedures
(the old note said only "coupled temperature-displacement", which is narrower
than the contract); TEMP/DTEMP meaning temperature-dependent properties are
unavailable, while thermal expansion still works because Abaqus reduces
STRAN/DSTRAN to mechanical strain; JSTEP(4) meaning a linear-perturbation step
gets a nonlinear update and an elastic-plastic Jacobian; and the deck-side
transverse-shear-stiffness requirement for beams and shells.

CMNAME handling no longer allocates. trim_fortran_name built an 80-character
std::string on every call — past the small-string buffer, so one heap
allocation plus a hash per integration point per global iteration. It now
returns a view, folding happens into a stack buffer, and the registry uses
heterogeneous lookup. The buffer is NUL-terminated so the same storage feeds
both the lookup and the C formatting in the error paths, which also removes the
last allocation from the fatal path.

Three comments were narrower than the truth and are corrected. The
row/column-major transpose is observable only under *USER MATERIAL, UNSYMM,
since Abaqus otherwise uses just the symmetric part of DDSDDE — worth saying,
because that is precisely the configuration a non-associative model needs. The
NDI/NSHR note claimed plane strain "happens to pass eps_33 = 0"; generalized
plane strain (CPEG) reports the same NDI/NSHR with a nonzero eps_33, and the
code is correct for it because it consumes whatever slot 2 holds. And the
element families that fall through to the fatal error — beams, trusses,
axisymmetric shells, cohesive elements — are now named, since that fallthrough
is the intended outcome rather than a gap.

Two facts that were true but undocumented. The `false` template argument in the
rank-4 conversions is INERT: tmech's abq_std honours _ShearStrain only on the
rank-2 path, so the tangent's no-scaling property comes from tmech's internals
rather than from the argument written there, and would silently acquire a factor
2 if tmech ever implemented it — the physical check in the tests is what
actually pins it. And the minor-symmetry guard on the tangent is an assert, so
absent under -DNDEBUG; the consequence is bounded and now stated precisely
(STRESS is unaffected, only the Jacobian degrades), which is why statev_map's
equivalent throws instead.

SCD is documented as never written, and its test assertion made meaningful: it
now passes a nonzero value in and requires it back unchanged, rather than
comparing zero against a zero-initialised variable — which a do-nothing
implementation satisfied. Also added: null guards on CMNAME and TIME, matching
the ones the other pointer arguments already had, and a note on why
thread_state's member order matters on use rather than on teardown.

Verified: 176/176 in Debug and Release, 77 tests under ASan+UBSan, the ABI and
threading tests under ThreadSanitizer, and -Wall -Wextra -Wconversion
-Wsign-conversion -Wshadow -Wpedantic clean across the umat headers (the
remaining warnings all originate in vendored tmech).
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