diff --git a/CMakeLists.txt b/CMakeLists.txt index dfe3cd7..6556295 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,24 @@ else() FetchContent_MakeAvailable(tmech) endif() +# --- Dependencies (nlohmann/json for the configuration layer) --- +# Fetched rather than left optional. Model configuration is meant to be JSON +# driven, so a build without it is missing the primary way to define a material, +# not an extra. It was previously reached only via __has_include against whatever +# happened to be installed system-wide, which meant the JSON tests silently +# vanished on a machine without it. +find_package(nlohmann_json 3.11 QUIET) +if(NOT nlohmann_json_FOUND) + FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE + ) + set(JSON_BuildTests OFF CACHE INTERNAL "") + FetchContent_MakeAvailable(nlohmann_json) +endif() + # --- Dependencies (Eigen for linear algebra) --- find_package(Eigen3 QUIET) if(NOT Eigen3_FOUND) @@ -76,7 +94,7 @@ target_include_directories(${PROJECT_NAME} ) target_compile_features(${PROJECT_NAME} INTERFACE cxx_std_23) -target_link_libraries(${PROJECT_NAME} INTERFACE numsim-core) +target_link_libraries(${PROJECT_NAME} INTERFACE numsim-core nlohmann_json::nlohmann_json) # tmech is header-only — add its include path without linking a target # (linking would pull it into the install export set) 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..bfe384a --- /dev/null +++ b/include/numsim-materials/materials/constant_scalar.h @@ -0,0 +1,48 @@ +#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. +/// +/// Lets a quantity that some material derives from — a modulus, a yield stress — +/// be a real graph edge instead of a parameter. The consumer is then ordered +/// after it by the topological sort, which is not true of a parameter: nothing +/// connects a parameter to the material that reads it. +/// +/// The property is PLAIN, not history, so statev_map never sees it and it costs +/// no STATEV slot. Use external_scalar_source instead when the value genuinely +/// changes per call and a consumer needs its old/new pair. +/// +/// No update callback: the value is set once, at construction, so the engine +/// skips this property entirely. +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_damage.h b/include/numsim-materials/materials/isotropic_damage.h index 889da36..3660aa7 100644 --- a/include/numsim-materials/materials/isotropic_damage.h +++ b/include/numsim-materials/materials/isotropic_damage.h @@ -24,6 +24,8 @@ namespace numsim::materials { /// damage_source::damage, damage_source::d_damage — from propagation law /// state_source::d_equivalent_strain — from state function /// yield_source::is_yielding — from yield function +/// The tangent may come from a different material than the stress: set the +/// optional "tangent_source". Absent, both come from "elastic_source". template class isotropic_damage final : public material_base, Traits> { @@ -50,8 +52,13 @@ class isotropic_damage final // inputs m_stress(base::template add_input( m_elastic_source, "stress", EdgeKind::Global)), + // Absent tangent_source: same material as the stress. contains() + // rather than an empty-string test, so "" is an error, not a fallback. m_tangent(base::template add_input( - m_elastic_source, "tangent", EdgeKind::Global)), + base::m_parameter_handler.contains("tangent_source") + ? base::template get_parameter("tangent_source") + : m_elastic_source, + "tangent", EdgeKind::Global)), m_damage(base::template add_input( m_damage_source, "damage", EdgeKind::Global)), m_d_damage(base::template add_input( @@ -65,6 +72,8 @@ class isotropic_damage final static input_parameter_controller parameters() { input_parameter_controller para{base::parameters()}; para.template insert("elastic_source").template add(); + // No check == optional; declared so the JSON schema still knows the key. + para.template insert("tangent_source"); para.template insert("damage_source").template add(); para.template insert("state_source").template add(); para.template insert("yield_source").template add(); diff --git a/include/numsim-materials/materials/isotropic_tangent.h b/include/numsim-materials/materials/isotropic_tangent.h new file mode 100644 index 0000000..0222837 --- /dev/null +++ b/include/numsim-materials/materials/isotropic_tangent.h @@ -0,0 +1,85 @@ +#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. +/// +/// linear_elasticity owns its tangent, and a material reading its own property +/// creates no graph edge — so elastic::stress sorts before elastic::tangent and +/// would consume a stale stiffness. Consuming ANOTHER material's property is a +/// real edge, which the topological sort honours. +/// +/// The moduli are inputs rather than parameters for the same reason: a +/// parameter has no edge to the material that reads it, so nothing orders a +/// change to it against the values derived from it. Wire from constant_scalar +/// when they are fixed, or from any scalar producer when they vary (temperature +/// dependence) — set K_property/G_property if it does not publish under "value". +/// Which one you wire IS the choice, and no flag can disagree with it. +/// +/// One job: rebuild the stiffness from its inputs on every update. No memo, so +/// no cached state to go stale and nothing to invalidate. The callback is always +/// bound, since inputs are not wired until finalize() and nothing can be +/// computed in a constructor that reads them. +/// +/// Costs a rank-4 rebuild per update (measured: ~309 ns against ~28 ns for a +/// memoised version). Where the moduli are fixed and that matters, +/// linear_elasticity computes its tangent once and is the cheaper choice. +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..0538d35 --- /dev/null +++ b/include/numsim-materials/materials/linear_stress.h @@ -0,0 +1,59 @@ +#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 instead of an owned +/// output. That is what makes the ordering correct: consuming another +/// material's property creates a graph edge; reading your own does not. +/// +/// Pair with any tangent generator. linear_elasticity stays the better choice +/// when the moduli are fixed — one fewer material, tangent computed once. +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/materials/weighted_sum.h b/include/numsim-materials/materials/weighted_sum.h index 3c19b29..3eaeb1e 100644 --- a/include/numsim-materials/materials/weighted_sum.h +++ b/include/numsim-materials/materials/weighted_sum.h @@ -1,6 +1,7 @@ #ifndef WEIGHTED_SUM_H #define WEIGHTED_SUM_H +#include #include #include "numsim-materials/core/material_base.h" @@ -18,6 +19,8 @@ namespace numsim::materials { /// Parameters: /// "name": material name /// "terms": vector> — (weight_name, constituent_name) pairs +/// "tangent_sources": optional vector — one per term, naming a +/// different material for that term's tangent; "" keeps the term's own. template class weighted_sum final : public material_base, Traits> { @@ -38,20 +41,44 @@ class weighted_sum final m_terms_param(base::template get_parameter("terms")), m_weight_property(base::template get_parameter("weight_property")), m_stress_property(base::template get_parameter("stress_property")), - m_tangent_property(base::template get_parameter("tangent_property")) + m_tangent_property(base::template get_parameter("tangent_property")), + m_tangent_sources(base::m_parameter_handler.contains("tangent_sources") + ? base::template get_parameter>("tangent_sources") + : std::vector{}) { + // Positional, so a shorter list would silently shift every override onto + // the wrong constituent — both names resolve and the only symptom is a + // wrong summed tangent. Require one entry per term, and let "" mean "this + // term keeps its own", so a non-leading term can be overridden alone. + if (!m_tangent_sources.empty() && + m_tangent_sources.size() != m_terms_param.size()) + throw std::runtime_error( + "weighted_sum '" + base::name() + "': tangent_sources has " + + std::to_string(m_tangent_sources.size()) + " entries but there are " + + std::to_string(m_terms_param.size()) + + " terms — supply one per term (\"\" keeps a term's own tangent) or " + "omit it entirely"); + // Dynamically create inputs for each term + std::size_t i = 0; for (const auto& [weight_name, mat_name] : m_terms_param) { + const bool overridden = + i < m_tangent_sources.size() && !m_tangent_sources[i].empty(); + const std::string& tangent_owner = + overridden ? m_tangent_sources[i] : mat_name; auto& w = base::template add_input(weight_name, m_weight_property, EdgeKind::Global); auto& s = base::template add_input(mat_name, m_stress_property, EdgeKind::Global); - auto& c = base::template add_input(mat_name, m_tangent_property, EdgeKind::Global); + auto& c = base::template add_input(tangent_owner, m_tangent_property, EdgeKind::Global); m_terms.push_back({&w, &s, &c}); + ++i; } - } static input_parameter_controller parameters() { input_parameter_controller para{base::parameters()}; + // Optional (no check): absent means every term uses its stress material. + // If given, one entry per term; "" keeps that term's own tangent. + para.template insert>("tangent_sources"); para.template insert("terms").template add(); para.template insert("weight_property") .template add(std::string{"value"}); @@ -94,6 +121,7 @@ class weighted_sum final const std::string& m_weight_property; const std::string& m_stress_property; const std::string& m_tangent_property; + const std::vector m_tangent_sources; std::vector m_terms; }; diff --git a/include/numsim-materials/umat/json_model.h b/include/numsim-materials/umat/json_model.h new file mode 100644 index 0000000..8d1c578 --- /dev/null +++ b/include/numsim-materials/umat/json_model.h @@ -0,0 +1,189 @@ +#ifndef NUMSIM_MATERIALS_UMAT_JSON_MODEL_H +#define NUMSIM_MATERIALS_UMAT_JSON_MODEL_H + +#include +#include +#include +#include +#include + +#include +#include "numsim-materials/default_materials.h" +#include "numsim-materials/io/json_material_factory.h" +#include "numsim-materials/core/input_types.h" +#include "numsim-materials/umat/errors.h" +#include "numsim-materials/umat/external_state_source.h" +#include "numsim-materials/umat/umat_interface.h" + +/// Define a UMAT model from JSON rather than from compiled C++. +/// +/// A builder written as a lambda forces a rebuild of the shared library for +/// every new material, which defeats the point of the deck driving the model. +/// This turns a JSON document into the same builder the registry already takes, +/// so a new material means editing a config file. +/// +/// The document is the one io/json_material_factory already understands, plus +/// an optional "constants" array binding the deck's *USER MATERIAL constants to +/// named parameters. It is spelled "constants" rather than "props" because in +/// this library a PROPERTY is a graph node — reusing that word for the deck's +/// numbers would name two unrelated things the same. "constants" is also what +/// the deck itself calls them (*USER MATERIAL, CONSTANTS=). +/// +/// { +/// "materials": [ +/// {"type": "external_strain_source", "name": "strain_in"}, +/// {"type": "constant_scalar", "name": "K", "value": 0}, +/// {"type": "constant_scalar", "name": "G", "value": 0}, +/// {"type": "isotropic_tangent", "name": "stiffness", +/// "K_source": "K", "G_source": "G"}, +/// {"type": "linear_stress", "name": "elastic", +/// "tangent_source": "stiffness", "strain_source": "strain_in"} +/// ], +/// "constants": ["K::value", "G::value"] +/// } +/// +/// PROPS[i] replaces the parameter named by constants[i], written +/// "material::parameter" — the same qualified-name syntax the rest of the +/// library uses for wiring ("time::state"), parsed by the same +/// connection_source::parse. Note the right-hand side is a PARAMETER here, not +/// a property. +/// +/// Values written in the document are placeholders for anything listed there. +/// Pairing this with constant_scalar means a deck constant enters as a graph +/// property, so consumers are ordered after it and follow it — see +/// materials/isotropic_tangent.h. +namespace numsim::materials::umat { + +/// Register the host-driven source materials with the runtime factory. +/// +/// Kept here rather than in register_default_materials() so the core defaults +/// stay free of any dependency on the UMAT layer; these materials only mean +/// something when a host is driving the graph. +template +void register_umat_materials() { + auto& factory = material_factory::instance(); + factory.template register_type>( + "external_strain_source"); + factory.template register_type>( + "external_scalar_source"); +} + +/// Parse a "material::parameter" target with the library's existing splitter, +/// so this does not invent a second syntax for a qualified name. +inline connection_source parse_constant_target(const std::string& target) { + try { + auto src = connection_source::parse(target); + if (src.material.empty() || src.property.empty()) throw std::invalid_argument(""); + return src; + } catch (const std::invalid_argument&) { + throw fatal_error( + "json_model: constants entry '" + target + + "' must be written \"material::parameter\""); + } +} + +/// Build a registry builder from a JSON document. +/// +/// Parsing happens once, here; the returned builder only substitutes PROPS and +/// creates. Any error in the document surfaces on the first UMAT call for the +/// material, as a fatal_error — a malformed config is a setup fault, not +/// something a smaller increment fixes. +template +typename umat_registry::builder make_json_builder( + const std::string& document) { + nlohmann::json parsed; + try { + parsed = nlohmann::json::parse(document); + } catch (const std::exception& e) { + throw fatal_error(std::string("json_model: cannot parse the model " + "document: ") + + e.what()); + } + if (!parsed.contains("materials") || !parsed["materials"].is_array()) + throw fatal_error("json_model: the document needs a \"materials\" array"); + + // An unrecognised top-level key is a setup fault, not something to ignore. + // A document still spelling the binding array "props" would otherwise be + // accepted with every constant silently unbound, leaving the placeholders in + // the document as the material's moduli. json_to_parameters already warns + // about unknown keys per material; this is the same check one level up. + for (const auto& [key, value] : parsed.items()) { + if (key == "materials" || key == "constants") continue; + throw fatal_error( + "json_model: unrecognised top-level key \"" + key + + "\"; the document takes \"materials\" and \"constants\"" + + (key == "props" ? " (the binding array is named \"constants\", since " + "\"property\" already means a graph node here)" + : "")); + } + + // Validate the props bindings now rather than on first use, so a typo is + // reported when the model is registered rather than mid-analysis. + std::vector bindings; + if (parsed.contains("constants")) { + if (!parsed["constants"].is_array()) + throw fatal_error("json_model: \"constants\" must be an array of " + "\"material::parameter\" strings"); + for (const auto& entry : parsed["constants"]) { + if (!entry.is_string()) + throw fatal_error( + "json_model: every \"constants\" entry must be a string"); + auto binding = parse_constant_target(entry.get()); + const bool known = std::any_of( + parsed["materials"].begin(), parsed["materials"].end(), + [&](const nlohmann::json& m) { + return m.contains("name") && + m["name"].get() == binding.material; + }); + if (!known) + throw fatal_error("json_model: constants entry targets material '" + + binding.material + + "', which the document does not define"); + bindings.push_back(std::move(binding)); + } + } + + return [parsed, bindings](material_context& ctx, + std::span props) { + static std::once_flag once; + std::call_once(once, [] { + register_default_materials(); + register_umat_materials(); + }); + + if (props.size() < bindings.size()) + throw fatal_error( + "json_model: the document binds " + std::to_string(bindings.size()) + + " material constants but the deck supplied " + + std::to_string(props.size()) + + " — check *USER MATERIAL, CONSTANTS="); + + // Substitute into a copy, so the registered document stays a template and + // a second thread building the same model is unaffected. + nlohmann::json doc = parsed; + for (std::size_t i = 0; i < bindings.size(); ++i) + for (auto& material : doc["materials"]) + if (material.contains("name") && + material["name"].get() == bindings[i].material) + material[bindings[i].property] = props[i]; + + for (const auto& material : doc["materials"]) + create_from_json(ctx, material); + ctx.finalize(); + }; +} + +/// Register a model defined by a JSON document. +template +void register_json_model( + std::string cmname, const std::string& document, + typename umat_registry::config cfg, + typename plane_stress_evaluator::options ps_opts = {}) { + umat_registry::instance().register_model( + std::move(cmname), make_json_builder(document), std::move(cfg), + ps_opts); +} + +} // namespace numsim::materials::umat + +#endif // NUMSIM_MATERIALS_UMAT_JSON_MODEL_H diff --git a/include/numsim-materials/umat/material_point_evaluator.h b/include/numsim-materials/umat/material_point_evaluator.h index bb377d3..614ea26 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,14 @@ 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", which is how monolithic materials + /// are configured. Set it when the stiffness is its own material. + /// optional, not an empty string, so unset and "" stay distinct. + /// + /// Appended rather than inserted: this header is embedded in third-party + /// UMATs, and a new field in the middle would silently re-bind the trailing + /// arguments of an existing aggregate initialiser. + std::optional tangent_source{}; }; /// One host call's arguments. @@ -111,8 +120,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/include/numsim-materials/umat/umat_interface.h b/include/numsim-materials/umat/umat_interface.h index b10ce03..b908a83 100644 --- a/include/numsim-materials/umat/umat_interface.h +++ b/include/numsim-materials/umat/umat_interface.h @@ -207,10 +207,10 @@ class umat_registry { std::unique_ptr ctx; std::unique_ptr solid; std::unique_ptr ps; - /// How many constants the context was built from. The graph is built once - /// and reused, so a later call arriving with a different count would mean - /// the cached parameters no longer describe this material. - std::size_t nprops{0}; + /// The constants the context was built from. The graph is built once and + /// reused, so a later call arriving with different ones would mean the + /// cached parameters no longer describe this material. + std::vector props; }; static std::unordered_mapsecond.nprops != props.size()) + // have distinct names — so anything different means the deck contradicts + // the cached graph, and the constants baked into it would be silently + // wrong for every subsequent call. Comparing the VALUES, not just the + // count: a same-length array with different numbers is the case that + // actually reaches a material, and NPROPS doubles is nothing next to an + // evaluation. + if (!std::equal(it->second.props.begin(), it->second.props.end(), + props.begin(), props.end())) throw fatal_error( "numsim UMAT: material '" + std::string(key) + - "' was built from " + std::to_string(it->second.nprops) + - " constants but this call supplies " + - std::to_string(props.size()) + - " — PROPS must be constant for a given material name"); + "' was built from a different set of " + + std::to_string(it->second.props.size()) + + " constants than this call supplies — PROPS must be constant for a " + "given material name; use distinct *MATERIAL names for distinct " + "constants"); return it->second; } @@ -269,7 +274,7 @@ class umat_registry { thread_state ts; ts.ctx = std::make_unique(); m.build(*ts.ctx, props); - ts.nprops = props.size(); + ts.props.assign(props.begin(), props.end()); if (!ts.ctx->is_finalized()) throw fatal_error( "the builder returned without calling finalize() on the context"); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8a7433d..d65d8c3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,9 @@ 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) +add_numsim_test(test_weighted_sum test_weighted_sum.cpp) +add_numsim_test(test_json_model test_json_model.cpp) target_link_libraries(test_umat_interface PRIVATE Threads::Threads) # Data dumper for plotting (not a test — standalone executable) diff --git a/tests/test_json_model.cpp b/tests/test_json_model.cpp new file mode 100644 index 0000000..2af52bb --- /dev/null +++ b/tests/test_json_model.cpp @@ -0,0 +1,207 @@ +#include +#include +#include +#include +#include +#include "numsim-materials/umat/json_model.h" + +// The Fortran-callable symbol, so the JSON path is exercised through the real +// ABI rather than only through the C++ evaluator. +NUMSIM_MATERIALS_DEFINE_UMAT(numsim::materials::material_policy_default) + +namespace { + +namespace nm = numsim::materials; +namespace u = numsim::materials::umat; + +using policy = nm::material_policy_default; +using T = policy::value_type; +using registry = u::umat_registry; + +/// Elastic model with the moduli bound to the deck's constants. Nothing here is +/// compiled: adding a material means editing this string. +const char* kElastic = R"({ + "materials": [ + {"type": "external_strain_source", "name": "strain_in"}, + {"type": "constant_scalar", "name": "K", "value": 0}, + {"type": "constant_scalar", "name": "G", "value": 0}, + {"type": "isotropic_tangent", "name": "stiffness", + "K_source": "K", "G_source": "G"}, + {"type": "linear_stress", "name": "elastic", + "tangent_source": "stiffness", "strain_source": "strain_in"} + ], + "constants": ["K::value", "G::value"] +})"; + +registry::config elastic_config() { + registry::config cfg; + cfg.strain_source = "strain_in"; + cfg.stress_source = "elastic"; + cfg.tangent_source = "stiffness"; + return cfg; +} + +struct fortran_name { + char buf[80]; + explicit fortran_name(const std::string& s) { + for (auto& c : buf) c = ' '; + for (std::size_t i = 0; i < s.size() && i < 80; ++i) buf[i] = s[i]; + } +}; + +/// DDSDDE(1,1) for a uniaxial increment, through the real umat_ entry point. +T uniaxial_tangent(const std::string& name, const T* props, int nprops) { + const fortran_name cm(name); + T statev[1] = {0}; + const T stran[6] = {0, 0, 0, 0, 0, 0}; + const T dstran[6] = {0.001, 0, 0, 0, 0, 0}; + T stress[6] = {0}, ddsdde[36] = {0}, pnewdt = 1.0; + T sse = 0, spd = 0, scd = 0, rpl = 0, ddsddt[6] = {0}, drplde[6] = {0}; + T drpldt = 0; + const T time[2] = {0, 0}; + T dtime = 0.1; + const T temp = 0, dtemp = 0, predef = 0, dpred = 0, celent = 1; + const T coords[3] = {0}, drot[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; + const T dfg[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; + int noel = 1, npt = 1, layer = 1, kspt = 1, jstep = 1, kinc = 1; + int ndi = 3, nshr = 3, ntens = 6, nstatv = 0; + + umat_(stress, statev, ddsdde, &sse, &spd, &scd, &rpl, ddsddt, drplde, &drpldt, + stran, dstran, time, &dtime, &temp, &dtemp, &predef, &dpred, cm.buf, + &ndi, &nshr, &ntens, &nstatv, props, &nprops, coords, drot, &pnewdt, + &celent, dfg, dfg, &noel, &npt, &layer, &kspt, &jstep, &kinc, 80); + + EXPECT_DOUBLE_EQ(pnewdt, 1.0); + return ddsdde[0]; +} + +struct Registration { + Registration() { + // One registered document, two deck materials — which is how a real deck + // expresses two parameter sets: distinct *MATERIAL names. + u::register_json_model("JSONSOFT", kElastic, elastic_config()); + u::register_json_model("JSONSTIFF", kElastic, elastic_config()); + u::register_json_model("JSONELASTIC", kElastic, elastic_config()); + } +}; +const Registration registration_{}; + +// --------------------------------------------------------------------------- + +/// The point of the whole exercise: the model is a document, the constants come +/// from the deck, and neither requires recompiling the UMAT. +TEST(JsonModel, DeckConstantsDriveAModelDefinedEntirelyInJson) { + const T soft[2] = {100.0, 40.0}; + const T stiff[2] = {300.0, 140.0}; + + EXPECT_NEAR(uniaxial_tangent("JSONSOFT", soft, 2), + 100.0 + 4.0 * 40.0 / 3.0, 1e-9); + EXPECT_NEAR(uniaxial_tangent("JSONSTIFF", stiff, 2), + 300.0 + 4.0 * 140.0 / 3.0, 1e-9); +} + +/// Values in the document are placeholders for anything listed in "constants" +/// — the deck wins. +TEST(JsonModel, DocumentValuesArePlaceholdersForBoundConstants) { + // The document says 0 for both; if substitution failed the tangent would be + // zero rather than wrong-but-plausible. + const T props[2] = {250.0, 90.0}; + EXPECT_NEAR(uniaxial_tangent("JSONELASTIC", props, 2), + 250.0 + 4.0 * 90.0 / 3.0, 1e-9); +} + +// --------------------------------------------------------------------------- +// Validation, at registration rather than mid-analysis +// --------------------------------------------------------------------------- + +TEST(JsonModel, RejectsAMalformedDocument) { + EXPECT_THROW(u::make_json_builder("{not json"), u::fatal_error); + EXPECT_THROW(u::make_json_builder(R"({"nope": 1})"), u::fatal_error); +} + +/// A document using the old "props" spelling would otherwise be accepted with +/// every constant unbound, leaving the placeholders as the material's moduli — +/// wrong but plausible, and completely silent. +TEST(JsonModel, RejectsAnUnrecognisedTopLevelKey) { + const char* old_spelling = R"({ + "materials": [{"type": "constant_scalar", "name": "K", "value": 0}], + "props": ["K::value"] + })"; + EXPECT_THROW(u::make_json_builder(old_spelling), u::fatal_error); + + const char* typo = R"({ + "materials": [{"type": "constant_scalar", "name": "K", "value": 0}], + "constant": ["K::value"] + })"; + EXPECT_THROW(u::make_json_builder(typo), u::fatal_error); +} + +TEST(JsonModel, RejectsAConstantsEntryThatIsNotQualified) { + const char* doc = R"({ + "materials": [{"type": "constant_scalar", "name": "K", "value": 0}], + "constants": ["Kvalue"] + })"; + EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); +} + +/// A constants entry naming a material the document does not define is a typo that +/// would otherwise substitute nothing and leave the placeholder in place — a +/// wrong-but-plausible modulus rather than an error. +TEST(JsonModel, RejectsAConstantsEntryTargetingAnUndefinedMaterial) { + const char* doc = R"({ + "materials": [{"type": "constant_scalar", "name": "K", "value": 0}], + "constants": ["Gee::value"] + })"; + EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); +} + +/// One material NAME carries one PROPS array. Supplying different constants for +/// a name whose graph is already built would otherwise return the first call's +/// stiffness forever, silently — the cached context is keyed on the name. +TEST(JsonModel, ChangingConstantsForOneMaterialNameIsFatal) { + const T first[2] = {100.0, 40.0}; + const T second[2] = {300.0, 140.0}; + + EXPECT_NEAR(uniaxial_tangent("JSONELASTIC", first, 2), + 100.0 + 4.0 * 40.0 / 3.0, 1e-9); + + int fatal_count = 0; + static int* counter = &fatal_count; + u::set_fatal_handler([](const char*) { ++*counter; }); + uniaxial_tangent("JSONELASTIC", second, 2); + u::set_fatal_handler(nullptr); + + EXPECT_EQ(fatal_count, 1) + << "same name, different constants must be reported, not ignored"; +} + +/// Fewer constants than the document binds is a *DEPVAR-style setup error, and +/// must be fatal rather than a cutback. +TEST(JsonModel, TooFewDeckConstantsIsFatal) { + const T only_one[1] = {100.0}; + const fortran_name cm("JSONELASTIC"); + T statev[1] = {0}; + const T stran[6] = {0}, dstran[6] = {0.001, 0, 0, 0, 0, 0}; + T stress[6] = {0}, ddsdde[36] = {0}, pnewdt = 1.0; + T sse = 0, spd = 0, scd = 0, rpl = 0, ddsddt[6] = {0}, drplde[6] = {0}; + T drpldt = 0; + const T time[2] = {0, 0}; + T dtime = 0.1; + const T temp = 0, dtemp = 0, predef = 0, dpred = 0, celent = 1; + const T coords[3] = {0}, drot[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; + const T dfg[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; + int noel = 1, npt = 1, layer = 1, kspt = 1, jstep = 1, kinc = 1; + int ndi = 3, nshr = 3, ntens = 6, nstatv = 0, nprops = 1; + + bool fatal = false; + u::set_fatal_handler([](const char*) {}); + umat_(stress, statev, ddsdde, &sse, &spd, &scd, &rpl, ddsddt, drplde, &drpldt, + stran, dstran, time, &dtime, &temp, &dtemp, &predef, &dpred, cm.buf, + &ndi, &nshr, &ntens, &nstatv, only_one, &nprops, coords, drot, &pnewdt, + &celent, dfg, dfg, &noel, &npt, &layer, &kspt, &jstep, &kinc, 80); + fatal = (pnewdt == 1.0); // fatal path leaves PNEWDT alone + u::set_fatal_handler(nullptr); + EXPECT_TRUE(fatal) << "a wrong CONSTANTS= count must not request a cutback"; +} + +} // namespace diff --git a/tests/test_tangent_generator.cpp b/tests/test_tangent_generator.cpp new file mode 100644 index 0000000..a901a2d --- /dev/null +++ b/tests/test_tangent_generator.cpp @@ -0,0 +1,414 @@ +#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, expressed through tmech rather than a component loop. +/// norm(a - b) is identically zero only when 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 +// --------------------------------------------------------------------------- + +/// A cross-material property is ordered before its consumer; an intra-material +/// one is 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 publishes a PLAIN property, so it costs no STATEV slot and +/// needs no exclusion. A history property here would be one wasted 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 nothing can be computed in the +/// constructor: the stiffness is built on the first update, not before it. +/// Everything that reads it goes through ctx.update() first, but the ordering +/// is worth pinning because it differs from the parameter-based version. +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. The +/// self-guard makes that cheap, but it is an optimisation with no observable +/// behaviour — this checks the observable part. +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 does move, the stiffness follows on the next update — +/// with the ordering guaranteed 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, tested against a PRE-EXISTING consumer. +/// small_strain_plasticity already names its tangent source, so it needs no +/// change: point elastic_source at the generator. +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 diff --git a/tests/test_weighted_sum.cpp b/tests/test_weighted_sum.cpp new file mode 100644 index 0000000..4562a60 --- /dev/null +++ b/tests/test_weighted_sum.cpp @@ -0,0 +1,215 @@ +#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_stress.h" +#include "numsim-materials/materials/scalar_identity_weight.h" +#include "numsim-materials/materials/scalar_stepper.h" +#include "numsim-materials/materials/weighted_sum.h" +#include "numsim-materials/umat/external_state_source.h" + +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; +using tensor4 = tmech::tensor; +using terms_type = std::vector>; + +constexpr T KA = 100.0, GA = 40.0; +constexpr T KB = 300.0, GB = 140.0; + +/// A weight in [0,1] published as "value", via scalar_identity_weight over a +/// scalar_stepper's history. +void add_weight(ctx_type& ctx, const std::string& name, T increment) { + param_type p; + p.insert("name", name + "_drv"); + p.insert("increment", increment); + ctx.create>(p); + p.clear(); + p.insert("name", name); + p.insert("source", name + "_drv::state"); + ctx.create>(p); +} + +void add_monolithic(ctx_type& ctx, const std::string& name, T k, T g) { + param_type p; + p.insert("name", name); + p.insert("strain_producer_name", "strain_in"); + p.insert("K", k); + p.insert("G", g); + ctx.create>(p); +} + +/// A constituent whose stiffness lives in its OWN material, so its stress and +/// tangent come from two different names. +void add_decomposed(ctx_type& ctx, const std::string& name, T k, T g) { + param_type p; + p.insert("name", name + "_K"); + p.insert("value", k); + ctx.create>(p); + p.clear(); + p.insert("name", name + "_G"); + p.insert("value", g); + ctx.create>(p); + p.clear(); + p.insert("name", name + "_stiff"); + p.insert("K_source", name + "_K"); + p.insert("G_source", name + "_G"); + ctx.create>(p); + p.clear(); + p.insert("name", name); + p.insert("tangent_source", name + "_stiff"); + p.insert("strain_source", "strain_in"); + ctx.create>(p); +} + +nm::external_strain_source& add_strain(ctx_type& ctx) { + param_type p; + p.insert("name", "strain_in"); + return ctx.create>(p); +} + +tensor2 uniaxial(T v) { + tensor2 e; + e.fill(0.0); + e(0, 0) = v; + return e; +} + +// --------------------------------------------------------------------------- +// Baseline: the mixture rule itself +// --------------------------------------------------------------------------- + +/// Two monolithic constituents at weights 0.25 and 0.5. Both stress and tangent +/// must be the weighted sum. +TEST(WeightedSum, SumsStressAndTangentByWeight) { + ctx_type ctx; + auto& src = add_strain(ctx); + add_weight(ctx, "wA", 0.25); + add_weight(ctx, "wB", 0.5); + add_monolithic(ctx, "matA", KA, GA); + add_monolithic(ctx, "matB", KB, GB); + + param_type p; + p.insert("name", "mix"); + p.insert("terms", {{"wA", "matA"}, {"wB", "matB"}}); + ctx.create>(p); + ctx.finalize(); + + src.bind(uniaxial(0.001), uniaxial(0.001)); + ctx.update(); + + const T cA = KA + 4.0 * GA / 3.0; + const T cB = KB + 4.0 * GB / 3.0; + EXPECT_NEAR(ctx.get("mix", "stress")(0, 0), + (0.25 * cA + 0.5 * cB) * 0.001, 1e-10); + EXPECT_NEAR(ctx.get("mix", "tangent")(0, 0, 0, 0), + 0.25 * cA + 0.5 * cB, 1e-9); +} + +// --------------------------------------------------------------------------- +// tangent_sources +// --------------------------------------------------------------------------- + +/// Absent: every term takes its tangent from the material producing its stress. +TEST(WeightedSum, AbsentTangentSourcesUsesEachTermsOwnMaterial) { + ctx_type ctx; + auto& src = add_strain(ctx); + add_weight(ctx, "wA", 1.0); + add_monolithic(ctx, "matA", KA, GA); + + param_type p; + p.insert("name", "mix"); + p.insert("terms", {{"wA", "matA"}}); + ctx.create>(p); + ctx.finalize(); + + src.bind(uniaxial(0.001), uniaxial(0.001)); + ctx.update(); + EXPECT_NEAR(ctx.get("mix", "tangent")(0, 0, 0, 0), + KA + 4.0 * GA / 3.0, 1e-9); +} + +/// The case tangent_sources exists for: a constituent whose stiffness is its own +/// material, so its stress and tangent have different owners. +TEST(WeightedSum, OverridesOneTermsTangentOwner) { + ctx_type ctx; + auto& src = add_strain(ctx); + add_weight(ctx, "wA", 1.0); + add_decomposed(ctx, "matA", KA, GA); + + param_type p; + p.insert("name", "mix"); + p.insert("terms", {{"wA", "matA"}}); + p.insert>("tangent_sources", {"matA_stiff"}); + ctx.create>(p); + ctx.finalize(); + + src.bind(uniaxial(0.001), uniaxial(0.001)); + ctx.update(); + EXPECT_NEAR(ctx.get("mix", "tangent")(0, 0, 0, 0), + KA + 4.0 * GA / 3.0, 1e-9); +} + +/// An empty entry keeps that term's own tangent, so a NON-LEADING term can be +/// overridden alone. Without it, a positional list could only ever override a +/// prefix, and a short list would silently shift every override left. +TEST(WeightedSum, EmptyEntryKeepsATermsOwnTangent) { + ctx_type ctx; + auto& src = add_strain(ctx); + add_weight(ctx, "wA", 0.5); + add_weight(ctx, "wB", 0.5); + add_monolithic(ctx, "matA", KA, GA); // keeps its own tangent + add_decomposed(ctx, "matB", KB, GB); // tangent lives elsewhere + + param_type p; + p.insert("name", "mix"); + p.insert("terms", {{"wA", "matA"}, {"wB", "matB"}}); + p.insert>("tangent_sources", {"", "matB_stiff"}); + ctx.create>(p); + ctx.finalize(); + + src.bind(uniaxial(0.001), uniaxial(0.001)); + ctx.update(); + + const T cA = KA + 4.0 * GA / 3.0; + const T cB = KB + 4.0 * GB / 3.0; + EXPECT_NEAR(ctx.get("mix", "tangent")(0, 0, 0, 0), + 0.5 * cA + 0.5 * cB, 1e-9); +} + +/// A shorter list is rejected. Positional matching means it would otherwise +/// apply the override to the WRONG constituent: both names resolve, +/// wire_inputs() succeeds, and the only symptom is a wrong summed tangent — +/// degraded Newton convergence while the stresses still converge correctly. +TEST(WeightedSum, RejectsATangentSourcesListThatDoesNotMatchTheTermCount) { + auto build = [](std::vector sources) { + ctx_type ctx; + add_strain(ctx); + add_weight(ctx, "wA", 0.5); + add_weight(ctx, "wB", 0.5); + add_monolithic(ctx, "matA", KA, GA); + add_decomposed(ctx, "matB", KB, GB); + param_type p; + p.insert("name", "mix"); + p.insert("terms", {{"wA", "matA"}, {"wB", "matB"}}); + p.insert>("tangent_sources", std::move(sources)); + ctx.create>(p); + }; + + EXPECT_THROW(build({"matB_stiff"}), std::runtime_error); // too short + EXPECT_THROW(build({"", "matB_stiff", "extra"}), std::runtime_error); // long + EXPECT_NO_THROW(build({"", "matB_stiff"})); // exact +} + +} // namespace