Skip to content

solvers: select the integration scheme by name (explicit vs implicit from the deck) - #45

Merged
petlenz merged 3 commits into
mainfrom
fix/tableau-by-name
Sep 6, 2026
Merged

solvers: select the integration scheme by name (explicit vs implicit from the deck)#45
petlenz merged 3 commits into
mainfrom
fix/tableau-by-name

Conversation

@petlenz

@petlenz petlenz commented Sep 6, 2026

Copy link
Copy Markdown
Member

Stacked on #40, which is where j2_rk_plasticity exists.

The question that found this

"What if I want a different solver — maybe explicit? I could not simply define that in the JSON file."

Checking that turned out to be worth more than the answer. Three documents:

j2_rk_plasticity (sdirk3, implicit)        Key tableau not found
j2_rk_plasticity EXPLICIT (forward_euler)  Key tableau not found
a typo in the scheme name                  Key tableau not found

"tableau" was never declared in the schema

The constructor read get_parameter<const butcher_tableau*>("tableau"). parameters() never inserted it. So it worked only from C++, where parameter_handler::insert() bypasses the schema — and from a document it failed identically whether the name was right, wrong, or absent.

That matters more than a missing parameter usually would, because the tableau is the explicit-versus-implicit choice. j2_rk_plasticity already handles explicit stages:

m_is_implicit[i] = std::abs(m_diag[i]) >= 1e-30;

So forward_euler was always an explicit J2 integrator. The capability existed and was unreachable from a deck.

Third instance of one pattern

A parameter the code reads but the schema does not declare is invisible to both the document layer and to validation. I had been treating these as three unrelated bugs.

The change

tableau is now a scheme name, resolved by tableau_by_name(), and it is declared. rk_integrator had the identical defect and is fixed the same way — leaving one of the two would have been worse than not starting.

An unknown name throws and lists the valid schemes rather than leaving the parameter absent:

butcher_tableau: unknown scheme 'sdirk4' -- expected one of: forward_euler,
explicit_midpoint, rk4, implicit_euler, implicit_midpoint, crank_nicolson,
sdirk3, gauss_legendre_4

Result

j2_rk_plasticity (sdirk3, implicit)        OK
j2_rk_plasticity EXPLICIT (forward_euler)  OK
a typo in the scheme name                  unknown scheme 'sdirk4' -- expected one of: ...

Two tests: every published scheme resolves and reports the right explicit/implicit character (forward_euler and rk4 explicit, sdirk3 and gauss_legendre_4 not), and a typo names itself and the alternatives.

55/55.

What this does not fix

The solver type is still compile-time fixed in j2_plasticity and drucker_prager_plasticityusing solver_type = local_newton<Traits>, wired by dynamic_cast. A document names which instance, never which kind. Naming a backward_euler there still reports the material as missing rather than as the wrong type, because wire_materials() collapses every exception into "absent".

That is a separate change and arguably not needed: choosing the integrator by material typej2_plasticity for implicit backward Euler, j2_rk_plasticity with a named scheme for everything else — is the same idiom as #40's backward_euler vs local_newton, and it avoids virtual dispatch on a per-integration-point path. Detail on #40.

Also here: the wiring error pointed the wrong way

wire_materials() wrapped the look-up and the type-check in one catch (...), discarding material_ref::wire()'s precise message. Naming a backward_euler where a local_newton is required produced:

wire_materials(): material 'j2' references missing materials: 'solver'

— sending the user to look for a material sitting right there in their document. The message did not just omit information, it pointed the wrong way. Now:

wire_materials(): material 'j2' references materials of the wrong type:
'solver' (they exist, but are not the type this material requires)

Both failure kinds are still collected and reported together, so a graph with one of each names both in one error. Two tests, one per mode; the wrong-type one is verified against the mutation that restores the single catch (...).

57/57.

… schema

"tableau" was never declared in parameters() at all. The constructor read
get_parameter<const butcher_tableau*>("tableau") while the schema never
inserted it, so it could only be supplied from C++, where parameter_handler's
insert() bypasses the schema. From a document it failed with "Key tableau not
found".

That mattered more than a missing parameter usually would: the tableau IS the
explicit-versus-implicit choice. j2_rk_plasticity already handles explicit
stages (m_is_implicit[i] = |a_ii| >= 1e-30), so forward_euler was always an
explicit J2 integrator -- the capability existed and was unreachable from a
deck.

The parameter is now a scheme NAME resolved by tableau_by_name(), and it is
declared. A raw pointer cannot be expressed in a document, which is the same
shape as Drucker-Prager's yield_function object (#33) and vector_newton's
zero_blocks (#17): a parameter the code reads but the schema does not declare
is invisible to both the document layer and to validation.

rk_integrator had the identical defect and is fixed the same way; leaving one
of the two would have been worse than not starting.

An unknown name now throws and lists the valid schemes, rather than leaving
the parameter absent. Two tests: every published scheme resolves and reports
the right explicit/implicit character, and a typo names itself and the
alternatives.

Verified from JSON:

  j2_rk_plasticity (sdirk3, implicit)        OK
  j2_rk_plasticity EXPLICIT (forward_euler)  OK
  a typo in the scheme name                  unknown scheme 'sdirk4' -- expected one of: ...
wire_materials() wrapped the look-up and the type-check in one catch(...), so
material_ref::wire()'s precise "is not of the requested type" was discarded and
the reference reported as missing instead.

The effect on a user: naming a backward_euler where a local_newton is required
produced

  wire_materials(): material 'j2' references missing materials: 'solver'

sending them to look for a material sitting right there in their document. That
is what makes it worth fixing rather than merely tidying -- the message does not
just omit information, it points the wrong way.

Now:

  wire_materials(): material 'j2' references materials of the wrong type:
  'solver' (they exist, but are not the type this material requires)

The two failures are collected separately and both are reported, so a graph with
one of each still names both in one error -- the collect-then-report behaviour
was deliberate and is kept.

Two tests, one per failure mode, and the wrong-type one is verified against the
mutation: restoring the single catch(...) fails it.
Making the scheme a named parameter turned an unreachable wrong answer into a
deck-selectable one, and the commit that did it advertised the new reachability
without checking the numbers.

The stage loop accumulates a(i,j) only for j < i, plus the diagonal. Any
coupling above the diagonal is dropped silently. gauss_legendre_4 has
a(0,1) = 0.25 - 1/(2*sqrt(3)), so it is integrated as something that is neither
Gauss-Legendre nor fourth order. Measured against the monolithic backward-Euler
reference on a 60-step uniaxial path:

  implicit_euler     alpha=0.034372   ratio 1.000   F  +0.0000
  sdirk3             alpha=0.034372   ratio 1.000   F  -0.0000
  forward_euler      alpha=0.034528   ratio 1.005   F  -0.1923
  rk4                alpha=0.033750   ratio 0.982   F  +0.7663
  gauss_legendre_4   alpha=-8.805943  ratio -256    F  +10880.3

A NEGATIVE equivalent plastic strain, no error, no warning. The explicit
schemes' small offsets are expected -- they do not enforce the yield condition
exactly -- but the last row is not an accuracy question.

rk_integrator dispatches this case to a fully-implicit solve. This class has no
such path, so it now refuses rather than pretending, naming the schemes it can
integrate. Adding that path would be the better fix and is a larger change than
this PR should carry.

Two tests: gauss_legendre_4 is rejected, and every DIRK or explicit scheme is
still accepted, so the guard cannot pass by refusing everything.

Found by an independent review of this PR. The same review claimed a 3x error
from j2_rk_plasticity.h:135 passing G where effective_modulus(G) = 3G is
expected. The argument IS inconsistent with the other two call sites, but the
measured effect is not 3x -- hardening dominates the denominator (3G = 231
against H' = 1000) and the incremental process self-corrects. Left alone here
and reported separately rather than fixed on a claim I could not reproduce.
@petlenz
petlenz changed the base branch from refactor/scalar-newton-split to main September 6, 2026 19:27
@petlenz
petlenz merged commit f4699fa into main Sep 6, 2026
1 check passed
@petlenz
petlenz deleted the fix/tableau-by-name branch September 6, 2026 19:28
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