diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bf20fd..f4823da 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) @@ -90,6 +108,23 @@ target_link_libraries(${PROJECT_NAME} INTERFACE numsim-core) # "the following imported targets are referenced, but are missing". set(NUMSIM_MATERIALS_EXPORTED_DEPS numsim-core) +# nlohmann/json is header-only, so it gets tmech's treatment: when it is FETCHED +# rather than found installed, linking the target pulls it into the install +# export set and install(EXPORT) rejects it for not being exported itself. +if(nlohmann_json_FOUND) + target_link_libraries(${PROJECT_NAME} INTERFACE nlohmann_json::nlohmann_json) + # Linked INTERFACE, so it is named in the exported target set and the + # generated Config has to re-find it. Missing here, a consumer of the + # installed package fails with "the link interface contains + # nlohmann_json::nlohmann_json but the target was not found". + list(APPEND NUMSIM_MATERIALS_EXPORTED_DEPS nlohmann_json) +else() + get_target_property(_njson_inc nlohmann_json INTERFACE_INCLUDE_DIRECTORIES) + if(_njson_inc) + target_include_directories(${PROJECT_NAME} INTERFACE ${_njson_inc}) + endif() +endif() + # tmech is header-only — add its include path without linking a target # (linking would pull it into the install export set) if(TARGET tmech) diff --git a/include/numsim-materials/umat/json_model.h b/include/numsim-materials/umat/json_model.h new file mode 100644 index 0000000..e8be7d4 --- /dev/null +++ b/include/numsim-materials/umat/json_model.h @@ -0,0 +1,258 @@ +#ifndef NUMSIM_MATERIALS_UMAT_JSON_MODEL_H +#define NUMSIM_MATERIALS_UMAT_JSON_MODEL_H + +#include +#include +#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 compiled C++, so a new material is +/// a config edit and not a rebuild of the shared library. +/// +/// io/json_material_factory's document, plus an optional "constants" array +/// binding the deck's *USER MATERIAL constants to named parameters: +/// +/// { +/// "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], in the library's own +/// qualified-name syntax ("time::state") and parsed by the same +/// connection_source::parse. Values in the document are placeholders for +/// anything listed there. +/// +/// Named "constants", not "props": a PROPERTY here is a graph node, and it is +/// what the deck calls them (*USER MATERIAL, CONSTANTS=). +namespace numsim::materials::umat { + +/// Host-driven source materials. Kept out of register_default_materials() so +/// the core defaults carry no dependency on the UMAT layer. +template +void register_umat_materials() { + auto& factory = material_factory::instance(); + factory.template register_type>( + "external_strain_source"); + factory.template register_type>( + "external_scalar_source"); +} + +/// The materials a document may name, registered once per Traits. +/// +/// Runs at REGISTRATION time too: checking targets against a material's +/// declared parameters needs a populated factory. Idempotent. +template +void ensure_materials_registered() { + static std::once_flag once; + std::call_once(once, [] { + register_default_materials(); + register_umat_materials(); + }); +} + +/// The parameter a binding will actually write. +/// +/// Its own function because it is the single place that has to stay in step +/// with the substitution loop below — validating one name and writing another +/// is how a target ends up half-checked. +inline std::string bound_parameter(const nlohmann::json& /*material*/, + const connection_source& binding) { + return binding.property; +} + +/// Reject a target naming a parameter the material does not declare. +/// +/// nlohmann::json CREATES a missing key rather than failing, so a misspelled +/// parameter is written where nothing reads it while the real one keeps its +/// placeholder — a wrong-but-plausible modulus behind a stderr warning. +template +void require_declared_parameter(const nlohmann::json& material, + const connection_source& binding, + const std::string& target) { + if (!material.contains("type") || !material["type"].is_string()) + throw fatal_error("json_model: material '" + binding.material + + "' has no \"type\", so \"" + target + + "\" cannot be checked against its parameters"); + + const auto type = material["type"].get(); + auto& factory = object_store::factory_type::instance(); + // An unknown type is caught at build time; not failing here keeps a document + // free to name a material the caller registers later. + if (!factory.contains(type)) return; + + // Declared AND numeric. Every material declares "name", and most declare + // *_source strings, so checking mere existence accepts targets that can only + // fail later — with a JSON type error rather than anything about decks. + const auto wanted = bound_parameter(material, binding); + const auto schema = factory.schema(type); + std::vector numeric; + bool declared = false, wanted_is_numeric = false; + for (const auto& [key, param] : schema) { + const auto tid = param->type_id(); + const bool is_num = tid == std::type_index(typeid(double)) || + tid == std::type_index(typeid(float)) || + tid == std::type_index(typeid(int)) || + tid == std::type_index(typeid(std::size_t)); + if (is_num) numeric.push_back(key); + if (key == wanted) { + declared = true; + wanted_is_numeric = is_num; + } + } + if (declared && wanted_is_numeric) return; + + std::sort(numeric.begin(), numeric.end()); + std::string known; + for (const auto& key : numeric) known += (known.empty() ? "" : ", ") + key; + throw fatal_error( + "json_model: constants entry '" + target + "' names " + + (declared + ? "'" + wanted + "', which " + type + " does not take as a number" + : "parameter '" + wanted + "', which " + type + " does not declare") + + " — a host constant can bind: " + (known.empty() ? "(nothing)" : known)); +} + +/// 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 builder only substitutes and creates. A +/// malformed document is a setup fault, so it raises fatal_error rather than +/// asking for a smaller increment. +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 key is a setup fault: a document still spelling the array + // "props" would be accepted with every constant unbound, leaving the + // placeholders as the moduli. Same check json_to_parameters does per + // material, 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)" + : "")); + } + + // Validated at registration rather than mid-analysis, and BOTH halves of the + // target — a check stopping at the material name reads as though the whole + // thing were verified. + 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"); + ensure_materials_registered(); + std::vector seen; + for (const auto& entry : parsed["constants"]) { + if (!entry.is_string()) + throw fatal_error( + "json_model: every \"constants\" entry must be a string"); + const auto target = entry.get(); + + // One constant per target: a repeat overwrites, dropping the earlier + // constant and leaving whatever it should have bound at its placeholder. + if (std::find(seen.begin(), seen.end(), target) != seen.end()) + throw fatal_error("json_model: constants entry '" + target + + "' appears twice; each host constant binds one " + "target, and a repeat silently drops the earlier one"); + seen.push_back(target); + + auto binding = parse_constant_target(target); + const nlohmann::json* owner = nullptr; + for (const auto& m : parsed["materials"]) + if (m.contains("name") && + m["name"].get() == binding.material) + owner = &m; + if (!owner) + throw fatal_error("json_model: constants entry targets material '" + + binding.material + + "', which the document does not define"); + require_declared_parameter(*owner, binding, target); + bindings.push_back(std::move(binding)); + } + } + + return [parsed, bindings](material_context& ctx, + std::span props) { + ensure_materials_registered(); + + 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="); + + // Into a copy, so the registered document stays a template. + 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index d03b294..f14a377 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ 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_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..0152ae6 --- /dev/null +++ b/tests/test_json_model.cpp @@ -0,0 +1,257 @@ +#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; + +/// Moduli bound to the deck's constants; nothing here is compiled. +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: the model is a document and the constants come from the deck, +/// with no recompile either way. +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); +} + +/// The old "props" spelling would be accepted with every constant unbound, +/// leaving the placeholders as the moduli — wrong, plausible, 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); +} + +/// The other half of the target. json CREATES a missing key rather than +/// failing, so a misspelled parameter went where nothing reads it while the +/// real one kept its placeholder: 7 + 4(90)/3 = 127 instead of 370, behind +/// nothing louder than a stderr warning. +TEST(JsonModel, RejectsAConstantsEntryNamingAnUndeclaredParameter) { + const char* doc = R"({ + "materials": [ + {"type": "constant_scalar", "name": "K", "value": 7.0} + ], + "constants": ["K::vlaue"] + })"; + EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); + + // The correct spelling still registers. + const char* good = R"({ + "materials": [ + {"type": "constant_scalar", "name": "K", "value": 7.0} + ], + "constants": ["K::value"] + })"; + EXPECT_NO_THROW(u::make_json_builder(good)); +} + +/// Declared is not enough: every material declares "name", and most declare +/// *_source strings. Binding a host constant to one of those can only fail +/// later, with a JSON type error that says nothing about decks. +TEST(JsonModel, RejectsAConstantsEntryNamingANonNumericParameter) { + const char* to_name = R"({ + "materials": [{"type": "constant_scalar", "name": "K", "value": 0}], + "constants": ["K::name"] + })"; + EXPECT_THROW(u::make_json_builder(to_name), u::fatal_error); + + const char* to_source = R"({ + "materials": [ + {"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"} + ], + "constants": ["stiffness::K_source"] + })"; + EXPECT_THROW(u::make_json_builder(to_source), u::fatal_error); +} + +/// A repeat overwrites, dropping one deck value and leaving what it should +/// have bound at its placeholder — a zero modulus, in the case that prompted +/// this. +TEST(JsonModel, RejectsADuplicateConstantsTarget) { + const char* doc = R"({ + "materials": [ + {"type": "constant_scalar", "name": "K", "value": 0}, + {"type": "constant_scalar", "name": "G", "value": 0} + ], + "constants": ["K::value", "K::value"] + })"; + EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); + + // Two entries against one material with different parameters is legitimate, + // so the key is the whole target. + const char* two_params = R"({ + "materials": [ + {"type": "external_strain_source", "name": "strain_in"}, + {"type": "linear_elasticity", "name": "el", + "strain_producer_name": "strain_in", "K": 0, "G": 0} + ], + "constants": ["el::K", "el::G"] + })"; + EXPECT_NO_THROW(u::make_json_builder(two_params)); +} + +/// A target naming an undefined material would substitute nothing and leave the +/// placeholder — 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); +} + +// Changing the constants for one material name is fatal, but that belongs to +// the registry — see UmatInterface.ChangingPropsValuesForTheSameNameIsFatal. + +/// Too few constants is a setup error: fatal, not 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