diff --git a/include/numsim-materials/default_materials.h b/include/numsim-materials/default_materials.h index 45aed64..1ba4672 100644 --- a/include/numsim-materials/default_materials.h +++ b/include/numsim-materials/default_materials.h @@ -6,7 +6,10 @@ #include "numsim-materials/solvers/backward_euler.h" #include "numsim-materials/solvers/vector_newton.h" #include "numsim-materials/materials/scalar_stepper.h" +#include "numsim-materials/materials/constant_scalar.h" +#include "numsim-materials/materials/isotropic_tangent.h" #include "numsim-materials/materials/linear_elasticity.h" +#include "numsim-materials/materials/linear_stress.h" #include "numsim-materials/materials/autocatalytic_reaction.h" #include "numsim-materials/materials/tensor_component_stepper.h" #include "numsim-materials/materials/scalar_identity_weight.h" @@ -69,6 +72,9 @@ void register_default_materials() { auto& factory = material_factory::instance(); factory.template register_type>("scalar_stepper"); factory.template register_type>("linear_elasticity"); + factory.template register_type>("constant_scalar"); + factory.template register_type>("isotropic_tangent"); + factory.template register_type>("linear_stress"); factory.template register_type>("autocatalytic_reaction"); factory.template register_type>("backward_euler"); factory.template register_type>("tensor_component_stepper_rank1"); diff --git a/include/numsim-materials/materials/constant_scalar.h b/include/numsim-materials/materials/constant_scalar.h new file mode 100644 index 0000000..52ff076 --- /dev/null +++ b/include/numsim-materials/materials/constant_scalar.h @@ -0,0 +1,41 @@ +#ifndef NUMSIM_MATERIALS_CONSTANT_SCALAR_H +#define NUMSIM_MATERIALS_CONSTANT_SCALAR_H + +#include "numsim-materials/core/material_base.h" + +namespace numsim::materials { + +/// A fixed scalar, published as a graph property. +/// +/// Makes a modulus or yield stress a real edge instead of a parameter, so the +/// consumer is ordered after it. Plain, not history, so it costs no STATEV +/// slot; use external_scalar_source when a consumer needs an old/new pair. +/// No update callback — the value is set once, at construction. +template +class constant_scalar final + : public material_base, Traits> { +public: + using base = material_base, Traits>; + using value_type = typename base::value_type; + using input_parameter_controller = typename base::input_parameter_controller; + + template + explicit constant_scalar(Args&&... args) + : base(std::forward(args)...), + m_value(base::template add_output("value")) { + m_value = base::template get_parameter("value"); + } + + static input_parameter_controller parameters() { + input_parameter_controller para{base::parameters()}; + para.template insert("value").template add(); + return para; + } + +private: + value_type& m_value; +}; + +} // namespace numsim::materials + +#endif // NUMSIM_MATERIALS_CONSTANT_SCALAR_H diff --git a/include/numsim-materials/materials/isotropic_tangent.h b/include/numsim-materials/materials/isotropic_tangent.h new file mode 100644 index 0000000..9649fac --- /dev/null +++ b/include/numsim-materials/materials/isotropic_tangent.h @@ -0,0 +1,78 @@ +#ifndef NUMSIM_MATERIALS_ISOTROPIC_TANGENT_H +#define NUMSIM_MATERIALS_ISOTROPIC_TANGENT_H + +#include +#include "numsim-materials/core/material_base.h" +#include "numsim-materials/materials/plasticity_utils.h" + +namespace numsim::materials { + +/// Isotropic elastic stiffness as a material, with K and G as graph inputs. +/// +/// A material reading its OWN property creates no edge, so linear_elasticity's +/// stress sorts before its tangent and would consume a stale stiffness. Reading +/// another material's property is a real edge the sort honours — which is why +/// the moduli are inputs rather than parameters. +/// +/// Wire from constant_scalar when fixed, or any scalar producer when they vary; +/// K_property/G_property if it does not publish under "value". Which one you +/// wire IS the choice, so no flag can disagree with it. +/// +/// Rebuilds on every update: no memo, so no cached state to go stale. The pair +/// costs ~315 ns per update against ~26 ns for linear_elasticity, which +/// computes its tangent once — so where the moduli are fixed and 12x matters, +/// that is still the cheaper material. +template +class isotropic_tangent final + : public material_base, Traits> { +public: + using base = material_base, Traits>; + using value_type = typename base::value_type; + using input_parameter_controller = typename base::input_parameter_controller; + using base::Dim; + using tensor4 = tmech::tensor; + + template + explicit isotropic_tangent(Args&&... args) + : base(std::forward(args)...), + m_C(base::template add_output( + "tangent", &isotropic_tangent::update_tangent)), + m_K(base::template add_input( + base::template get_parameter("K_source"), + base::template get_parameter("K_property"), + EdgeKind::Global)), + m_G(base::template add_input( + base::template get_parameter("G_source"), + base::template get_parameter("G_property"), + EdgeKind::Global)) {} + + static input_parameter_controller parameters() { + input_parameter_controller para{base::parameters()}; + para.template insert("K_source").template add(); + para.template insert("G_source").template add(); + // Defaults to the scalar-output convention constant_scalar and + // scalar_identity_weight follow. Override for a source that publishes under + // another name — external_scalar_source publishes "state", for instance. + para.template insert("K_property") + .template add(std::string{"value"}); + para.template insert("G_property") + .template add(std::string{"value"}); + return para; + } + + void update_tangent() { + const auto I{tmech::eye()}; + const auto IIvol{tmech::otimes(I, I) / Dim}; + m_C = 3 * m_K.get() * IIvol + + 2 * m_G.get() * plasticity_detail::make_IIdev(); + } + +private: + tensor4& m_C; + const input_property& m_K; + const input_property& m_G; +}; + +} // namespace numsim::materials + +#endif // NUMSIM_MATERIALS_ISOTROPIC_TANGENT_H diff --git a/include/numsim-materials/materials/linear_stress.h b/include/numsim-materials/materials/linear_stress.h new file mode 100644 index 0000000..7488ef7 --- /dev/null +++ b/include/numsim-materials/materials/linear_stress.h @@ -0,0 +1,57 @@ +#ifndef NUMSIM_MATERIALS_LINEAR_STRESS_H +#define NUMSIM_MATERIALS_LINEAR_STRESS_H + +#include +#include "numsim-materials/core/material_base.h" + +namespace numsim::materials { + +/// sigma = C : eps, with C supplied by another material. +/// +/// linear_elasticity with the tangent as a Global input rather than an owned +/// output, which is what makes the ordering correct: another material's +/// property is an edge, your own is not. Pair with any tangent generator; +/// linear_elasticity stays better when the moduli are fixed. +template +class linear_stress final + : public material_base, Traits> { +public: + using base = material_base, Traits>; + using value_type = typename base::value_type; + using input_parameter_controller = typename base::input_parameter_controller; + using base::Dim; + using tensor2 = tmech::tensor; + using tensor4 = tmech::tensor; + + template + explicit linear_stress(Args&&... args) + : base(std::forward(args)...), + m_sig(base::template add_output( + "stress", &linear_stress::update_stress)), + m_C(base::template add_input( + base::template get_parameter("tangent_source"), + "tangent", EdgeKind::Global)), + m_eps(base::template add_input( + base::template get_parameter("strain_source"), + "strain", EdgeKind::Global)) {} + + static input_parameter_controller parameters() { + input_parameter_controller para{base::parameters()}; + para.template insert("tangent_source") + .template add(); + para.template insert("strain_source") + .template add(); + return para; + } + + void update_stress() { m_sig = tmech::dcontract(m_C.get(), m_eps.get()); } + +private: + tensor2& m_sig; + const input_property& m_C; + const input_property& m_eps; +}; + +} // namespace numsim::materials + +#endif // NUMSIM_MATERIALS_LINEAR_STRESS_H diff --git a/include/numsim-materials/umat/material_point_evaluator.h b/include/numsim-materials/umat/material_point_evaluator.h index bb377d3..7f2bb4c 100644 --- a/include/numsim-materials/umat/material_point_evaluator.h +++ b/include/numsim-materials/umat/material_point_evaluator.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -43,7 +44,7 @@ class material_point_evaluator { struct config { /// Name of the external_strain_source material. std::string strain_source; - /// Material producing the stress and tangent the host wants back. + /// Material producing the stress the host wants back. std::string stress_source; std::string stress_property{"stress"}; std::string tangent_property{"tangent"}; @@ -59,6 +60,11 @@ class material_point_evaluator { /// Additional host-owned history to keep out of STATEV, beyond the strain /// and time sources (which are excluded automatically). std::vector extra_exclusions{}; + /// Unset means "same as stress_source"; set it when the stiffness is its + /// own material. Optional rather than "" so the two stay distinct, and + /// APPENDED — a field inserted mid-struct would silently re-bind the + /// trailing arguments of an existing aggregate initialiser. + std::optional tangent_source{}; }; /// One host call's arguments. @@ -111,8 +117,9 @@ class material_point_evaluator { m_stress = resolve_property(m_cfg.stress_source, m_cfg.stress_property); - m_tangent = - resolve_property(m_cfg.stress_source, m_cfg.tangent_property); + const std::string& tangent_owner = + m_cfg.tangent_source ? *m_cfg.tangent_source : m_cfg.stress_source; + m_tangent = resolve_property(tangent_owner, m_cfg.tangent_property); if (!m_cfg.plastic_strain_property.empty()) { const auto src = connection_source::parse(m_cfg.plastic_strain_property); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8a7433d..d03b294 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,7 @@ add_numsim_test(test_statev_map test_statev_map.cpp) add_numsim_test(test_material_point_evaluator test_material_point_evaluator.cpp) add_numsim_test(test_plane_stress_evaluator test_plane_stress_evaluator.cpp) add_numsim_test(test_umat_interface test_umat_interface.cpp) +add_numsim_test(test_tangent_generator test_tangent_generator.cpp) target_link_libraries(test_umat_interface PRIVATE Threads::Threads) # Data dumper for plotting (not a test — standalone executable) diff --git a/tests/test_tangent_generator.cpp b/tests/test_tangent_generator.cpp new file mode 100644 index 0000000..216fd9d --- /dev/null +++ b/tests/test_tangent_generator.cpp @@ -0,0 +1,408 @@ +#include +#include +#include +#include +#include +#include +#include "numsim-materials/core/material_context.h" +#include "numsim-materials/materials/constant_scalar.h" +#include "numsim-materials/materials/isotropic_tangent.h" +#include "numsim-materials/materials/linear_elasticity.h" +#include "numsim-materials/materials/linear_isotropic_hardening.h" +#include "numsim-materials/materials/linear_stress.h" +#include "numsim-materials/materials/small_strain_plasticity.h" +#include "numsim-materials/solvers/backward_euler.h" +#include "numsim-materials/umat/external_state_source.h" +#include "numsim-materials/umat/material_point_evaluator.h" +#include "numsim-materials/umat/statev_map.h" +#include "numsim-materials/umat/tensor_conversion.h" + +namespace { + +namespace nm = numsim::materials; +namespace u = numsim::materials::umat; + +using policy = nm::material_policy_default; +using T = policy::value_type; +using ctx_type = nm::material_context; +using param_type = policy::ParameterHandler; +using tensor2 = tmech::tensor; +using tensor4 = tmech::tensor; + +constexpr T K = 166.67; +constexpr T G = 76.92; + +/// strain source + constant moduli -> isotropic_tangent -> linear_stress +nm::external_strain_source& build_decomposed(ctx_type& ctx, T k, T g) { + param_type p; + p.insert("name", "strain_in"); + auto& src = ctx.create>(p); + + p.clear(); + p.insert("name", "K"); + p.insert("value", k); + ctx.create>(p); + + p.clear(); + p.insert("name", "G"); + p.insert("value", g); + ctx.create>(p); + + p.clear(); + p.insert("name", "stiffness"); + p.insert("K_source", "K"); + p.insert("G_source", "G"); + ctx.create>(p); + + p.clear(); + p.insert("name", "elastic"); + p.insert("tangent_source", "stiffness"); + p.insert("strain_source", "strain_in"); + ctx.create>(p); + + ctx.finalize(); + return src; +} + +/// v * (e1 (x) e1) — a uniaxial strain, built as a tensor expression. +tensor2 uniaxial(T v) { + tmech::tensor e1; + e1.fill(0.0); + e1(0) = 1.0; + tensor2 out; + out = v * tmech::otimes(e1, e1); + return out; +} + +/// The isotropic stiffness the generator should produce, built independently. +tensor4 isotropic(T k, T g) { + const auto I = tmech::eye(); + const auto IIsym = (tmech::otimesu(I, I) + tmech::otimesl(I, I)) * 0.5; + const auto IIvol = tmech::otimes(I, I) / 3.0; + tensor4 C; + C = 3.0 * k * IIvol + 2.0 * g * (IIsym - IIvol); + return C; +} + +/// Exact tensor equality: norm(a - b) is zero only if every component matches. +template +::testing::AssertionResult TensorsIdentical(const A& a, const B& b) { + const auto d = tmech::norm(a - b); + if (d == T{0}) return ::testing::AssertionSuccess(); + return ::testing::AssertionFailure() << "norm(a - b) = " << d; +} + +// --------------------------------------------------------------------------- +// Ordering — what the decomposition exists for +// --------------------------------------------------------------------------- + +/// Cross-material properties are ordered before their consumer; intra-material +/// ones are not. Both the moduli and the stiffness are edges here. +TEST(TangentGenerator, EveryProducerIsOrderedBeforeItsConsumer) { + ctx_type ctx; + build_decomposed(ctx, K, G); + + // optional, not 0: a producer legitimately lands at index 0, so a 0 sentinel + // would pass even with the property missing from the graph. + std::optional i_k, i_g, i_tangent, i_stress; + std::size_t n = 0; + for (const auto* prop : ctx.property_execution_order()) { + const auto& id = prop->traits().id; + if (id.owner == "K" && id.name == "value") i_k = n; + if (id.owner == "G" && id.name == "value") i_g = n; + if (id.owner == "stiffness" && id.name == "tangent") i_tangent = n; + if (id.owner == "elastic" && id.name == "stress") i_stress = n; + ++n; + } + ASSERT_TRUE(i_k.has_value() && i_g.has_value()); + ASSERT_TRUE(i_tangent.has_value()) << "stiffness::tangent is not in the graph"; + ASSERT_TRUE(i_stress.has_value()) << "elastic::stress is not in the graph"; + + EXPECT_LT(*i_k, *i_tangent); + EXPECT_LT(*i_g, *i_tangent); + EXPECT_LT(*i_tangent, *i_stress); +} + +// --------------------------------------------------------------------------- +// Equivalence with the monolithic material +// --------------------------------------------------------------------------- + +/// Same physics, different graph shape: must agree exactly. +TEST(TangentGenerator, MatchesLinearElasticityExactly) { + ctx_type dec; + auto& dec_src = build_decomposed(dec, K, G); + + ctx_type mono; + nm::external_strain_source* mono_src = nullptr; + { + param_type p; + p.insert("name", "strain_in"); + mono_src = &mono.create>(p); + p.clear(); + p.insert("name", "elastic"); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", K); + p.insert("G", G); + mono.create>(p); + mono.finalize(); + } + + for (int step = 1; step <= 5; ++step) { + const auto eps = uniaxial(0.001 * step); + dec_src.bind(eps, eps); + mono_src->bind(eps, eps); + dec.update(); + mono.update(); + + EXPECT_TRUE(TensorsIdentical(dec.get("elastic", "stress"), + mono.get("elastic", "stress"))) + << "step " << step; + } + + EXPECT_TRUE(TensorsIdentical(dec.get("stiffness", "tangent"), + mono.get("elastic", "tangent"))); +} + +// --------------------------------------------------------------------------- +// Constants as materials +// --------------------------------------------------------------------------- + +/// constant_scalar is a PLAIN property: no STATEV slot, no exclusion. History +/// would cost one slot per integration point, per constant. +TEST(TangentGenerator, ConstantsCostNoStatevSlot) { + ctx_type ctx; + build_decomposed(ctx, K, G); + // Only the host-driven strain is excluded; the moduli are not mentioned. + const u::statev_map map(ctx, {{"strain_in", "strain"}}); + EXPECT_EQ(map.nstatv(), 0u); +} + +/// Inputs are not wired until finalize(), so the stiffness is built on the +/// first update, not in the constructor. Differs from the parameter version, +/// so worth pinning. +TEST(TangentGenerator, TangentIsBuiltOnTheFirstUpdateNotAtConstruction) { + ctx_type ctx; + auto& src = build_decomposed(ctx, K, G); + + // Default-constructed until something runs the callback. + tensor4 zero; + zero.fill(0.0); + EXPECT_TRUE(TensorsIdentical(ctx.get("stiffness", "tangent"), zero)); + + const auto eps = uniaxial(0.001); + src.bind(eps, eps); + ctx.update(); + + EXPECT_TRUE(tmech::almost_equal(ctx.get("stiffness", "tangent"), + isotropic(K, G), 1e-12)); +} + +/// Repeated updates with fixed moduli must keep giving the same answer. +TEST(TangentGenerator, RepeatedUpdatesWithFixedModuliAreStable) { + ctx_type ctx; + auto& src = build_decomposed(ctx, K, G); + const auto eps = uniaxial(0.001); + src.bind(eps, eps); + ctx.update(); + const tensor2 first = ctx.get("elastic", "stress"); + for (int i = 0; i < 20; ++i) ctx.update(); + EXPECT_TRUE(TensorsIdentical(ctx.get("elastic", "stress"), first)); +} + +/// And when a modulus moves, the stiffness follows — ordered by the edge, not +/// by registration order. +TEST(TangentGenerator, StiffnessFollowsAChangedModulus) { + ctx_type ctx; + auto& src = build_decomposed(ctx, K, G); + + const auto eps = uniaxial(0.001); + src.bind(eps, eps); + ctx.update(); + tensor2 expected; + expected = tmech::dcontract(isotropic(K, G), eps); + EXPECT_TRUE(tmech::almost_equal(ctx.get("elastic", "stress"), + expected, 1e-12)); + + // Write the constant material's published value directly. + ctx.get_mutable("K", "value") = 2 * K; + ctx.update(); + + expected = tmech::dcontract(isotropic(2 * K, G), eps); + EXPECT_TRUE(tmech::almost_equal(ctx.get("elastic", "stress"), + expected, 1e-12)); +} + +// --------------------------------------------------------------------------- +// Composition with a pre-existing consumer +// --------------------------------------------------------------------------- + +/// The drop-in claim, against a PRE-EXISTING consumer: small_strain_plasticity +/// already names its tangent source, so only elastic_source moves. +TEST(TangentGenerator, DrivesJ2PlasticityIdenticallyToLinearElasticity) { + auto drive = [](bool decomposed) { + ctx_type ctx; + param_type p; + p.insert("name", "strain_in"); + auto& src = ctx.create>(p); + + if (decomposed) { + p.clear(); + p.insert("name", "K"); + p.insert("value", K); + ctx.create>(p); + p.clear(); + p.insert("name", "G"); + p.insert("value", G); + ctx.create>(p); + p.clear(); + p.insert("name", "stiffness"); + p.insert("K_source", "K"); + p.insert("G_source", "G"); + ctx.create>(p); + } else { + p.clear(); + p.insert("name", "stiffness"); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", K); + p.insert("G", G); + ctx.create>(p); + } + + p.clear(); + p.insert("name", "solver"); + ctx.create>(p); + p.clear(); + p.insert("name", "hardening"); + p.insert("source", "j2"); + p.insert("K", T{1000}); + ctx.create>(p); + p.clear(); + p.insert("name", "j2"); + p.insert("elastic_source", "stiffness"); + p.insert("hardening_source", "hardening"); + p.insert("strain_source", "strain_in"); + p.insert("solver_source", "solver"); + p.insert("G", G); + p.insert("sigma_0", T{50}); + ctx.create>(p); + ctx.finalize(); + + std::vector> out; + for (int step = 1; step <= 30; ++step) { + const auto eps = uniaxial(0.02 * step); + src.bind(eps, eps); + ctx.update(); + out.emplace_back(ctx.get("j2", "stress"), + ctx.get("j2", "equivalent_plastic_strain")); + ctx.commit(); + } + return out; + }; + + const auto with_generator = drive(true); + const auto with_monolith = drive(false); + ASSERT_EQ(with_generator.size(), with_monolith.size()); + bool went_plastic = false; + for (std::size_t i = 0; i < with_generator.size(); ++i) { + EXPECT_TRUE(TensorsIdentical(with_generator[i].first, + with_monolith[i].first)) + << "stress at step " << i; + EXPECT_DOUBLE_EQ(with_generator[i].second, with_monolith[i].second) + << "equivalent plastic strain at step " << i; + if (with_generator[i].second > 1e-8) went_plastic = true; + } + EXPECT_TRUE(went_plastic) << "the path must yield for this to mean anything"; +} + +// --------------------------------------------------------------------------- +// The optional tangent_source +// --------------------------------------------------------------------------- + +/// Absent tangent_source falls back to the stress source; supplied is honoured. +TEST(TangentSource, AbsentFallsBackToTheStressSource) { + ctx_type ctx; + param_type p; + p.insert("name", "strain_in"); + ctx.create>(p); + p.clear(); + p.insert("name", "elastic"); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", K); + p.insert("G", G); + ctx.create>(p); + ctx.finalize(); + + u::material_point_evaluator::config cfg; + cfg.strain_source = "strain_in"; + cfg.stress_source = "elastic"; + ASSERT_FALSE(cfg.tangent_source.has_value()); + EXPECT_NO_THROW(u::material_point_evaluator(ctx, cfg)); +} + +TEST(TangentSource, SuppliedResolvesTheTangentElsewhere) { + ctx_type ctx; + build_decomposed(ctx, K, G); + + u::material_point_evaluator::config cfg; + cfg.strain_source = "strain_in"; + cfg.stress_source = "elastic"; // linear_stress publishes only "stress" + EXPECT_THROW(u::material_point_evaluator(ctx, cfg), u::fatal_error); + + cfg.tangent_source = "stiffness"; + EXPECT_NO_THROW(u::material_point_evaluator(ctx, cfg)); +} + +/// The decomposed pair driving a UMAT end to end. +TEST(TangentSource, DecomposedPairDrivesTheEvaluatorLikeLinearElasticity) { + ctx_type dec; + build_decomposed(dec, K, G); + u::material_point_evaluator::config dcfg; + dcfg.strain_source = "strain_in"; + dcfg.stress_source = "elastic"; + dcfg.tangent_source = "stiffness"; + u::material_point_evaluator deval(dec, dcfg); + + ctx_type mono; + { + param_type p; + p.insert("name", "strain_in"); + mono.create>(p); + p.clear(); + p.insert("name", "elastic"); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", K); + p.insert("G", G); + mono.create>(p); + mono.finalize(); + } + u::material_point_evaluator::config mcfg; + mcfg.strain_source = "strain_in"; + mcfg.stress_source = "elastic"; + u::material_point_evaluator meval(mono, mcfg); + + std::vector dsv(deval.nstatv(), 0.0), msv(meval.nstatv(), 0.0); + T stran[6] = {0, 0, 0, 0, 0, 0}; + const T dstran[6] = {0.002, -0.0005, 0.0, 0.001, 0.0, 0.0}; + T ds[6], dd[36], ms[6], md[36]; + + for (int step = 0; step < 10; ++step) { + deval.evaluate({.stran = stran, .dstran = dstran, .stress = ds, + .ddsdde = dd, .statev = dsv}); + meval.evaluate({.stran = stran, .dstran = dstran, .stress = ms, + .ddsdde = md, .statev = msv}); + + // Compare in tensor space rather than slot by slot: a slot permutation on + // both sides would cancel in a componentwise check, and the tensors are + // what the host actually consumes. + EXPECT_TRUE(TensorsIdentical(u::stress_from_buffer(ds), + u::stress_from_buffer(ms))) + << "stress at step " << step; + EXPECT_TRUE(TensorsIdentical(u::tangent_from_buffer(dd), + u::tangent_from_buffer(md))) + << "tangent at step " << step; + + for (std::size_t i = 0; i < 6; ++i) stran[i] += dstran[i]; + } +} + +} // namespace