diff --git a/include/numsim-materials/core/material_interface.h b/include/numsim-materials/core/material_interface.h index a14760b..cff881f 100644 --- a/include/numsim-materials/core/material_interface.h +++ b/include/numsim-materials/core/material_interface.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -51,6 +53,41 @@ class material_interface { return m_parameter_handler.template get(std::forward(key)); } + /// Overwrite a parameter in place, so references bound by get_parameter() + /// stay valid and see the new value. + /// + /// Assigns through the non-const get(), NOT insert(): insert_or_assign + /// replaces the whole std::any, relocating anything past its small buffer. + /// + /// T is deliberately NOT deduced. The stored type must be named exactly, so + /// set_parameter("K", 250) is fine while set_parameter("K", 250) + /// fails to compile instead of throwing bad_any_cast at run time. + /// + /// Reaches only what the material re-reads through its bound reference. It + /// does NOT affect anything derived at construction (linear_elasticity's + /// tangent), copied into a member, or consumed once for wiring (source + /// names) — those keep their original values with no error. The write is also + /// local to THIS material, since each holds its own copy of the handler. + template + void set_parameter(std::string const& key, + std::type_identity_t const& value) { + // The identity is cached in m_name and used as the registry key, so a write + // here would leave the parameter disagreeing with both. + if (key == "name") + throw std::invalid_argument( + "material_interface::set_parameter(): 'name' is the material's " + "identity and cannot be changed after construction"); + try { + m_parameter_handler.template get(key) = value; + } catch (const std::bad_any_cast&) { + // Otherwise this surfaces as a bare "bad any_cast" naming nothing, and + // from a type that is neither invalid_argument nor runtime_error. + throw std::invalid_argument( + "material_interface::set_parameter('" + key + + "'): the requested type does not match the stored one"); + } + } + const auto& get_property_registry() const { return m_property_handler; } /// Wire all property inputs. Called at finalize(). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8a7433d..ffd9f2f 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_set_parameter test_set_parameter.cpp) target_link_libraries(test_umat_interface PRIVATE Threads::Threads) # Data dumper for plotting (not a test — standalone executable) diff --git a/tests/test_set_parameter.cpp b/tests/test_set_parameter.cpp new file mode 100644 index 0000000..8fefd24 --- /dev/null +++ b/tests/test_set_parameter.cpp @@ -0,0 +1,280 @@ +#include +#include +#include +#include +#include +#include "numsim-materials/core/material_base.h" +#include "numsim-materials/core/material_context.h" +#include "numsim-materials/materials/linear_elasticity.h" +#include "numsim-materials/umat/external_state_source.h" +#include + +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; // named: commas break the gtest macros + +/// Too large for std::any's small buffer — a scalar would hide the bug. +struct big_parameter { + std::array data{}; +}; + +/// Binds parameters by reference as real materials do, and exposes them. +template +class probe_material 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 probe_material(Args&&... args) + : base(std::forward(args)...), + m_k(base::template get_parameter("K")), + m_big(base::template get_parameter("big")) {} + + static input_parameter_controller parameters() { + input_parameter_controller para{base::parameters()}; + para.template insert("K").template add(); + para.template insert("big").template add(); + return para; + } + + const value_type& bound_k() const noexcept { return m_k; } + const big_parameter& bound_big() const noexcept { return m_big; } + +private: + const value_type& m_k; + const big_parameter& m_big; +}; + +probe_material& build(ctx_type& ctx) { + param_type p; + p.insert("name", "probe"); + p.insert("K", 100.0); + p.insert("big", big_parameter{}); + auto& m = ctx.create>(p); + ctx.finalize(); + return m; +} + +// --------------------------------------------------------------------------- + +/// The only useful write is one the material's bound reference observes. +TEST(SetParameter, BoundReferenceSeesTheNewValue) { + ctx_type ctx; + auto& m = build(ctx); + + EXPECT_DOUBLE_EQ(m.bound_k(), 100.0); + m.template set_parameter("K", 250.0); + EXPECT_DOUBLE_EQ(m.bound_k(), 250.0); +} + +/// The address must not move, for any type — which is why set_parameter +/// assigns rather than calling insert(). +TEST(SetParameter, WriteDoesNotRelocateEvenForALargeType) { + ctx_type ctx; + auto& m = build(ctx); + + const void* addr_before = static_cast(&m.bound_big()); + big_parameter v; + v.data[0] = 7.0; + v.data[31] = 9.0; + m.template set_parameter("big", v); + + EXPECT_EQ(static_cast(&m.bound_big()), addr_before) + << "the bound reference must remain valid"; + EXPECT_DOUBLE_EQ(m.bound_big().data[0], 7.0); + EXPECT_DOUBLE_EQ(m.bound_big().data[31], 9.0); +} + +/// The same guarantee against parameter_handler directly, so the rationale +/// lives next to the mechanism. +TEST(SetParameter, InsertRelocatesALargeValueButAssignmentDoesNot) { + numsim_core::parameter_handler<> h; + h.insert("big", big_parameter{}); + const void* bound = static_cast(&h.get("big")); + + h.insert("big", big_parameter{}); + const void* after_insert = + static_cast(&h.get("big")); + + h.get("big").data[0] = 1.0; + const void* after_assign = + static_cast(&h.get("big")); + + EXPECT_NE(after_insert, bound) + << "insert() is expected to relocate; if that changes, revisit why " + "set_parameter assigns instead"; + EXPECT_EQ(after_assign, after_insert) + << "assignment through get() must never relocate"; +} + +TEST(SetParameter, ThrowsForAnUnknownKey) { + ctx_type ctx; + auto& m = build(ctx); + EXPECT_THROW(m.template set_parameter("nosuchkey", 1.0), + std::invalid_argument); +} + +/// No collateral damage to neighbouring parameters. +TEST(SetParameter, LeavesOtherParametersAlone) { + ctx_type ctx; + auto& m = build(ctx); + + big_parameter v; + v.data[5] = 3.0; + m.template set_parameter("big", v); + m.template set_parameter("K", 42.0); + + EXPECT_DOUBLE_EQ(m.bound_k(), 42.0); + EXPECT_DOUBLE_EQ(m.bound_big().data[5], 3.0); + EXPECT_EQ(m.name(), "probe"); +} + + +// --------------------------------------------------------------------------- +// Against SHIPPED materials +// +// The probe above binds every parameter by reference and derives nothing, which +// is the one shape this API handles cleanly. These pin what happens with real +// materials, where it does not. +// --------------------------------------------------------------------------- + +/// linear_elasticity computes its tangent in the constructor and registers +/// "tangent" with no callback, so a write to K lands in the parameter and +/// changes nothing. Pinned, not endorsed: this is the limitation the API +/// documents, and the reason isotropic_tangent's "recompute" exists. +TEST(SetParameterShippedMaterials, WriteLandsButDerivedStateGoesStale) { + ctx_type ctx; + param_type p; + p.insert("name", "strain_in"); + auto& src = ctx.create>(p); + p.clear(); + p.insert("name", "elastic"); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", 100.0); + p.insert("G", 40.0); + auto& el = ctx.create>(p); + ctx.finalize(); + + tensor2 eps; + eps.fill(0.0); + eps(0, 0) = 0.001; + src.bind(eps, eps); + ctx.update(); + const T before = ctx.get("elastic", "stress")(0, 0); + EXPECT_NEAR(before, (100.0 + 4.0 * 40.0 / 3.0) * 0.001, 1e-12); + + el.template set_parameter("K", 200.0); + + // The write is real... + EXPECT_DOUBLE_EQ(el.template get_parameter("K"), 200.0); + // ... and has no effect, because the tangent was built once. + ctx.update(); + EXPECT_DOUBLE_EQ(ctx.get("elastic", "stress")(0, 0), + before); +} + +/// Each material holds its own COPY of the handler, so a write reaches one +/// material and not the caller's handler or any sibling built from it. +TEST(SetParameterShippedMaterials, WriteIsLocalToTheMaterial) { + ctx_type ctx; + param_type p; + p.insert("name", "probe"); + p.insert("K", 100.0); + p.insert("big", big_parameter{}); + auto& m = ctx.create>(p); + ctx.finalize(); + + m.template set_parameter("K", 777.0); + + EXPECT_DOUBLE_EQ(m.bound_k(), 777.0); + EXPECT_DOUBLE_EQ(p.get("K"), 100.0) + << "the caller's handler is a separate copy"; +} + +/// A wiring parameter is consumed once, at construction, to build the input. +/// Writing it afterwards cannot re-wire anything. +TEST(SetParameterShippedMaterials, WritingAWiringKeyDoesNotRewire) { + ctx_type ctx; + param_type p; + p.insert("name", "strain_in"); + auto& src = ctx.create>(p); + p.clear(); + p.insert("name", "other"); + ctx.create>(p); + p.clear(); + p.insert("name", "elastic"); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", 100.0); + p.insert("G", 40.0); + auto& el = ctx.create>(p); + ctx.finalize(); + + el.template set_parameter("strain_producer_name", "other"); + + tensor2 eps; + eps.fill(0.0); + eps(0, 0) = 0.002; + src.bind(eps, eps); // the ORIGINAL source + ctx.update(); + // Still reading strain_in, so the stress follows it despite the write. + EXPECT_NEAR(ctx.get("elastic", "stress")(0, 0), + (100.0 + 4.0 * 40.0 / 3.0) * 0.002, 1e-12); +} + +// --------------------------------------------------------------------------- +// Guards +// --------------------------------------------------------------------------- + +/// "name" is cached in m_name and used as the registry key, so writing it would +/// desynchronise the parameter from the material's identity. +TEST(SetParameter, RejectsWritingTheIdentityKey) { + ctx_type ctx; + auto& m = build(ctx); + EXPECT_THROW(m.template set_parameter("name", "renamed"), + std::invalid_argument); + EXPECT_EQ(m.name(), "probe"); +} + +/// A type mismatch must name the key. Without the translation it surfaces as a +/// bare "bad any_cast" that is neither invalid_argument nor runtime_error, so a +/// UMAT boundary catching those would miss it entirely. +TEST(SetParameter, TypeMismatchThrowsADiagnosticNotBadAnyCast) { + ctx_type ctx; + auto& m = build(ctx); + try { + m.template set_parameter("K", 1.0f); // "K" is stored as double + FAIL() << "expected a diagnostic"; + } catch (const std::invalid_argument& e) { + EXPECT_NE(std::string(e.what()).find("K"), std::string::npos) << e.what(); + } + EXPECT_DOUBLE_EQ(m.bound_k(), 100.0) << "the value must be unchanged"; +} + +/// T is not deduced, so a wrong-typed literal is a compile error rather than a +/// run-time throw. +template +struct deduces_t : std::false_type {}; +template +struct deduces_t().set_parameter( + std::declval(), 250))>> + : std::true_type {}; + +TEST(SetParameter, TypeIsNotDeduced) { + static_assert(!deduces_t>::value, + "set_parameter must not deduce T from the value"); + ctx_type ctx; + auto& m = build(ctx); + m.template set_parameter("K", 250); // int converts to the named double + EXPECT_DOUBLE_EQ(m.bound_k(), 250.0); +} + +} // namespace