From 1231363b4358670fbc03932c4a5d26a65394a54c Mon Sep 17 00:00:00 2001 From: petlenz Date: Sun, 23 Aug 2026 00:50:02 +0200 Subject: [PATCH] tests: cover the five materials that had none exponential_isotropic_hardening, linear_damage_law, scalar_complement_weight, strain_energy_state_function and vector_strain_state_function were referenced by no test. Three are the SECOND of a pair whose first is well covered -- the exponential hardening beside the linear one, the linear damage law beside the exponential one, the complement weight beside the identity weight. The first of a pair gets a test when it is written and the second inherits the assumption that it is the same shape. Asserted against the closed forms rather than against recorded output: exponential hardening H = K_inf (1 - e^-dk), dH = K_inf d e^-dk, plus the saturation that distinguishes it from linear hardening linear damage law all three branches, including the 0.999 cap that keeps (1-d) away from zero, and a zero range not dividing by 0 complement weight value = 1 - source strain energy d(eps_eq)/d(eps) against a central difference vector strain |n.eps.m|, and that the derivative carries the sign the measure discards The two hardening/damage materials take their input from a specific producer property, so the tests use a small stub publishing one scalar under a chosen name. That isolates the material instead of testing it behind a full plasticity model, and building one context per value also shows it is a pure function of its input. Two of my own assertions were wrong first and are worth recording: I read 'hardening_modulus' as H when it is dH -- the material publishes H as 'hardening_stress' -- and asserted H < K_inf at kappa = 20, which is mathematically true and numerically false once exp(-160) underflows. weighted_sum, also listed in #34, is covered on feature/tangent-source-consumers and is not duplicated here. Closes #34. --- tests/CMakeLists.txt | 1 + tests/test_uncovered_materials.cpp | 282 +++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 tests/test_uncovered_materials.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4425a79..6d637fe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -26,6 +26,7 @@ add_numsim_test(test_umat_interface test_umat_interface.cpp) add_numsim_test(test_tangent_generator test_tangent_generator.cpp) add_numsim_test(test_json_model test_json_model.cpp) add_numsim_test(test_props_scalar test_props_scalar.cpp) +add_numsim_test(test_uncovered_materials test_uncovered_materials.cpp) target_link_libraries(test_umat_interface PRIVATE Threads::Threads) # Data dumper for plotting (not a test — standalone executable) diff --git a/tests/test_uncovered_materials.cpp b/tests/test_uncovered_materials.cpp new file mode 100644 index 0000000..b72fbee --- /dev/null +++ b/tests/test_uncovered_materials.cpp @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include +#include "numsim-materials/core/material_context.h" +#include "numsim-materials/materials/exponential_isotropic_hardening.h" +#include "numsim-materials/materials/linear_damage_law.h" +#include "numsim-materials/materials/linear_elasticity.h" +#include "numsim-materials/materials/scalar_complement_weight.h" +#include "numsim-materials/materials/scalar_stepper.h" +#include "numsim-materials/materials/strain_energy_state_function.h" +#include "numsim-materials/materials/tensor_component_stepper.h" +#include "numsim-materials/materials/vector_strain_state_function.h" + +/// Materials that had no test at all. Three of the five are the SECOND of a +/// pair whose first is well covered -- exponential hardening beside linear, +/// the linear damage law beside the exponential one, the complement weight +/// beside the identity weight -- which is the pattern that produced the gap. +namespace { + +namespace nm = numsim::materials; +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; + +/// Publishes one scalar under a caller-chosen property name, so a material can +/// be exercised in isolation instead of behind a full plasticity model. The +/// value is fixed per context; tests that need a sweep build one context per +/// point, which also proves the material is a pure function of its input. +template +class scalar_source_stub final + : public nm::material_base, Traits> { +public: + using base = nm::material_base, Traits>; + using value_type = typename base::value_type; + using input_parameter_controller = typename base::input_parameter_controller; + + template + explicit scalar_source_stub(Args&&... args) + : base(std::forward(args)...), + m_value(base::template add_output( + base::m_parameter_handler.template get("property"))) { + m_value = base::template get_parameter("value"); + } + + static input_parameter_controller parameters() { + input_parameter_controller para{base::parameters()}; + para.template insert("property") + .template add(); + para.template insert("value").template add(); + return para; + } + +private: + value_type& m_value; +}; + +/// A context holding just the stub and the material under test. +template +std::unique_ptr with_scalar_source(const std::string& property, + T value, param_type mat) { + auto ctx = std::make_unique(); + param_type p; + p.insert("name", "src"); + p.insert("property", property); + p.insert("value", value); + ctx->template create>(p); + ctx->template create(mat); + ctx->finalize(); + ctx->update(); + return ctx; +} + +/// A scalar_stepper driving `name`, so a scalar consumer has a live source. +void add_driver(ctx_type& ctx, const std::string& name, T increment) { + param_type p; + p.insert("name", name); + p.insert("increment", increment); + ctx.create>(p); +} + +// --------------------------------------------------------------------------- + +/// H = K_inf (1 - e^{-delta k}), dH = K_inf delta e^{-delta k}. +/// Saturating, unlike the linear variant: the point of the material is that H +/// approaches K_inf and dH decays, so both ends are asserted. +TEST(ExponentialIsotropicHardening, MatchesTheClosedForm) { + constexpr T K_inf = 250.0, delta = 8.0; + auto at = [&](T kappa) { + param_type m; + m.insert("name", "hardening"); + m.insert("source", "src"); + m.insert("K_inf", K_inf); + m.insert("delta", delta); + return with_scalar_source>( + "equivalent_plastic_strain", kappa, m); + }; + + for (const T kappa : {0.0, 0.01, 0.05, 0.2, 0.5}) { + const auto ctx = at(kappa); + const T e = std::exp(-delta * kappa); + // hardening_stress is H; hardening_modulus is dH/dkappa. + EXPECT_NEAR(ctx->get("hardening", "hardening_stress"), + K_inf * (1.0 - e), 1e-10) << "kappa = " << kappa; + EXPECT_NEAR(ctx->get("hardening", "hardening_modulus"), + K_inf * delta * e, 1e-10) << "kappa = " << kappa; + } + + // Saturation is what distinguishes this from linear_isotropic_hardening: + // H approaches K_inf rather than growing without bound. + // Strictly below K_inf while the exponential is still representable... + const auto mid = at(1.0); + EXPECT_LT(mid->get("hardening", "hardening_stress"), K_inf); + EXPECT_GT(mid->get("hardening", "hardening_stress"), 0.99 * K_inf); + // ...and indistinguishable from it once exp(-delta*kappa) underflows, which + // is the saturation that distinguishes this from linear_isotropic_hardening. + const auto far = at(20.0); + EXPECT_NEAR(far->get("hardening", "hardening_stress"), K_inf, 1e-12); + EXPECT_NEAR(far->get("hardening", "hardening_modulus"), 0.0, 1e-12); +} + +// --------------------------------------------------------------------------- + +/// Three branches: below kappa_0 undamaged, above kappa_f capped at 0.999, +/// linear in between. The cap exists so (1-d) never reaches zero; asserting it +/// pins a deliberate choice that reads like a magic number otherwise. +TEST(LinearDamageLaw, HasThreeBranchesIncludingTheCap) { + constexpr T k0 = 0.10, kf = 0.30; + auto at = [&](T kappa) { + param_type m; + m.insert("name", "damage"); + m.insert("yield_source", "src"); + m.insert("kappa_0", k0); + m.insert("kappa_f", kf); + return with_scalar_source>("kappa", kappa, m); + }; + + // below kappa_0 — undamaged + for (const T kappa : {0.0, 0.05, 0.10}) { + const auto c = at(kappa); + EXPECT_NEAR(c->get("damage", "damage"), 0.0, 1e-12) << kappa; + EXPECT_NEAR(c->get("damage", "d_damage"), 0.0, 1e-12) << kappa; + } + // between — linear ramp with constant slope + for (const T kappa : {0.15, 0.20, 0.25}) { + const auto c = at(kappa); + EXPECT_NEAR(c->get("damage", "damage"), (kappa - k0) / (kf - k0), 1e-10); + EXPECT_NEAR(c->get("damage", "d_damage"), 1.0 / (kf - k0), 1e-10); + } + // at and beyond kappa_f — capped, so (1-d) never reaches zero. Pinning the + // cap keeps a deliberate choice from reading as a magic number. + for (const T kappa : {0.30, 0.50, 5.0}) { + const auto c = at(kappa); + EXPECT_NEAR(c->get("damage", "damage"), 0.999, 1e-12) << kappa; + EXPECT_NEAR(c->get("damage", "d_damage"), 0.0, 1e-12) << kappa; + } +} + +/// A degenerate range must not divide by zero. +TEST(LinearDamageLaw, ZeroRangeIsUndamagedRatherThanInfinite) { + param_type m; + m.insert("name", "damage"); + m.insert("yield_source", "src"); + m.insert("kappa_0", 0.2); + m.insert("kappa_f", 0.2); + const auto c = + with_scalar_source>("kappa", 0.5, m); + EXPECT_TRUE(std::isfinite(c->get("damage", "damage"))); + EXPECT_TRUE(std::isfinite(c->get("damage", "d_damage"))); + EXPECT_NEAR(c->get("damage", "d_damage"), 0.0, 1e-12); +} + +// --------------------------------------------------------------------------- + +/// value = 1 - source. Trivial, and its partner scalar_identity_weight is +/// tested; the pair is exactly where an untested second variant hides. +TEST(ScalarComplementWeight, IsOneMinusItsSource) { + constexpr T inc = 0.1; + ctx_type ctx; + add_driver(ctx, "frac", inc); + param_type p; + p.insert("name", "complement"); + p.insert("source", "frac::state"); + ctx.create>(p); + ctx.finalize(); + + for (int step = 1; step <= 8; ++step) { + ctx.update(); + EXPECT_NEAR(ctx.get("complement", "value"), 1.0 - inc * step, 1e-12) + << "step " << step; + ctx.commit(); + } +} + +// --------------------------------------------------------------------------- + +/// eps_eq = sqrt(2 W / E) is scalar; the material's job is its DERIVATIVE +/// with respect to strain, which drives damage. Checked against a central +/// difference of eps_eq, which is the property that has to hold. +TEST(StrainEnergyStateFunction, DerivativeMatchesAFiniteDifference) { + constexpr T K = 166.67, G = 76.92, E_mod = 9 * K * G / (3 * K + G); + auto build = [&](T e11) { + auto ctx = std::make_unique(); + param_type p; + p.insert("name", "stepper"); + p.insert("increment", e11); + p.insert>("indices", {0, 0}); + ctx->create>(p); + p.clear(); + p.insert("name", "elastic"); + p.insert("strain_producer_name", "stepper"); + p.insert("K", K); p.insert("G", G); + ctx->create>(p); + p.clear(); + p.insert("name", "state"); + p.insert("strain_source", "stepper"); + p.insert("tangent_source", "elastic"); + p.insert("youngs_modulus", E_mod); + ctx->create>(p); + ctx->finalize(); + ctx->update(); + return ctx; + }; + + constexpr T e0 = 0.01, h = 1e-6; + const auto plus = build(e0 + h); + const auto minus = build(e0 - h); + const auto at = build(e0); + + const T fd = (plus->get("state", "equivalent_strain") - + minus->get("state", "equivalent_strain")) / (2 * h); + const auto& d = at->get("state", "d_equivalent_strain"); + EXPECT_NEAR(d(0, 0), fd, 1e-5) + << "d(equivalent_strain)/d(eps_11) must match a central difference"; + EXPECT_GT(at->get("state", "equivalent_strain"), 0.0); +} + +// --------------------------------------------------------------------------- + +/// eps_eq = |n . eps . m|, so the derivative is the symmetrised n (x) m, and +/// the absolute value must flip its sign under a sign change of the strain. +TEST(VectorStrainStateFunction, PicksOneComponentAndIsSignSymmetric) { + auto build = [](T e11) { + auto ctx = std::make_unique(); + param_type p; + p.insert("name", "stepper"); + p.insert("increment", e11); + p.insert>("indices", {0, 0}); + ctx->create>(p); + p.clear(); + p.insert("name", "state"); + p.insert("strain_source", "stepper"); + p.insert>("n_direction", {0}); + p.insert>("m_direction", {0}); + ctx->create>(p); + ctx->finalize(); + ctx->update(); + return ctx; + }; + + const auto pos = build(0.02); + const auto neg = build(-0.02); + + // n = m = e_0, so the measure is |eps_11| either way. + EXPECT_NEAR(pos->get("state", "equivalent_strain"), 0.02, 1e-12); + EXPECT_NEAR(neg->get("state", "equivalent_strain"), 0.02, 1e-12); + + // The derivative carries the sign, so it flips while the measure does not. + const auto& dp = pos->get("state", "d_equivalent_strain"); + const auto& dn = neg->get("state", "d_equivalent_strain"); + EXPECT_NEAR(dp(0, 0), 1.0, 1e-12); + EXPECT_NEAR(dn(0, 0), -1.0, 1e-12); + // and it is confined to the selected component + EXPECT_NEAR(dp(1, 1), 0.0, 1e-12); + EXPECT_NEAR(dp(0, 1), 0.0, 1e-12); +} + +} // namespace