From a06cd60a7747fdfe77ea176a64b41d694101e7f7 Mon Sep 17 00:00:00 2001 From: petlenz Date: Sun, 16 Aug 2026 09:13:58 +0200 Subject: [PATCH 1/7] umat: define models in JSON, with the deck's constants bound into the graph A builder written as a C++ lambda means recompiling the shared library for every new material, which defeats the point of the deck driving the model. make_json_builder turns a JSON document into the same builder the registry already takes, so a new material is a config edit. The document is the one io/json_material_factory already understands, plus a "constants" array binding *USER MATERIAL, CONSTANTS= to named parameters: "constants": ["K::value", "G::value"] Named "constants" rather than "props" because a PROPERTY here is a graph node, and reusing that word for the deck's numbers names two unrelated things the same. Targets use the library's existing "material::parameter" syntax and go through connection_source::parse rather than inventing a second spelling. Any unrecognised top-level key is rejected. Ignoring one meant a document still saying "props" was accepted with every constant UNBOUND, leaving the placeholder zeros in the document as the material's moduli -- wrong, plausible and silent. json_to_parameters already warns per material; this is the same check one level up. nlohmann/json moves from an optional __has_include probe to a declared dependency with a FetchContent fallback. Configuration is meant to be JSON driven, so a build without it is missing the primary way to define a material, not an extra -- and the probe meant the JSON tests silently vanished on a machine without it installed. 7 tests, driven through the real umat_ entry point rather than the C++ evaluator, so the whole path from Fortran ABI to graph is covered. --- CMakeLists.txt | 20 ++- include/numsim-materials/umat/json_model.h | 189 ++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_json_model.cpp | 192 +++++++++++++++++++++ 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 include/numsim-materials/umat/json_model.h create mode 100644 tests/test_json_model.cpp 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/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/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..c476bd7 --- /dev/null +++ b/tests/test_json_model.cpp @@ -0,0 +1,192 @@ +#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, and supplying different constants +// for a name whose graph is already built is fatal. That belongs to the +// registry rather than to this layer, so it is tested against the registry +// directly — see UmatInterface.ChangingPropsValuesForTheSameNameIsFatal. + +/// 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 From 1c1b2c670bf4e9c405ff82fa71a5f17583fc1e47 Mon Sep 17 00:00:00 2001 From: petlenz Date: Mon, 17 Aug 2026 22:47:56 +0200 Subject: [PATCH 2/7] umat: validate both halves of a constants target, and reject duplicates Two review findings on the JSON model layer, both reproduced first. 1. Only the MATERIAL half of a "material::parameter" target was checked. The substitution is material[property] = props[i] on a nlohmann::json object, which CREATES a missing key rather than failing -- so a misspelled parameter was written where nothing reads it while the real one kept the document's placeholder. With "K::vlaue" against a placeholder of 7 and a deck supplying 250, the tangent came back 127 = 7 + 4(90)/3 instead of 370, with no fatal and no cutback: a stderr warning was the only trace, and in a real job that is buried in solver output if it is captured at all. The comment above that check claimed typos were reported at registration. True for the material half, false for the parameter half -- worse than not claiming it, since it reads as though the whole target were verified. Targets are now checked against the material's declared parameters, listing what the type does take. That needs a populated factory, so registration of the default materials moves out of the builder into a helper both call. A type the factory does not know is skipped rather than rejected, so a document stays free to name a material the caller registers afterwards. 2. Nothing rejected the same target twice. ["K::value", "K::value"] assigned props[0] then props[1] to one key -- last write wins, props[0] dropped, and G never bound at all, leaving a shear modulus of zero. The key is the whole target, since two entries against one material with different parameters is legitimate. Four tests, each paired with the near-miss that must still be accepted so the checks cannot pass by rejecting everything. --- include/numsim-materials/umat/json_model.h | 102 ++++++++++++++++++--- tests/test_json_model.cpp | 53 +++++++++++ 2 files changed, 140 insertions(+), 15 deletions(-) diff --git a/include/numsim-materials/umat/json_model.h b/include/numsim-materials/umat/json_model.h index 8d1c578..777b6b0 100644 --- a/include/numsim-materials/umat/json_model.h +++ b/include/numsim-materials/umat/json_model.h @@ -68,6 +68,67 @@ void register_umat_materials() { "external_scalar_source"); } +/// Register the materials a document may name, once per Traits. +/// +/// Hoisted out of the builder so it also runs at REGISTRATION time: the +/// binding targets are checked against each material's declared parameters, +/// and that needs a populated factory. Registering types is 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. +/// +/// Checking only that the MATERIAL exists leaves the other half of the target +/// unvalidated, and nlohmann::json CREATES a missing key rather than failing — +/// so a misspelled parameter is written to a key nothing reads while the real +/// one keeps the document's placeholder. The result is a wrong-but-plausible +/// modulus behind a stderr warning, in a job that reports no error at all. +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(); + // A type the factory does not know is caught when the graph is built. Not + // failing here keeps a document free to name a material the caller registers + // after this one. + if (!factory.contains(type)) return; + + const auto wanted = bound_parameter(material, binding); + std::vector declared; + for (const auto& [key, unused] : factory.schema(type)) declared.push_back(key); + if (std::find(declared.begin(), declared.end(), wanted) != declared.end()) + return; + + std::string known; + std::sort(declared.begin(), declared.end()); + for (const auto& key : declared) known += (known.empty() ? "" : ", ") + key; + throw fatal_error("json_model: constants entry '" + target + + "' names parameter '" + wanted + "', which " + type + + " does not declare — it takes: " + 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) { @@ -117,39 +178,50 @@ typename umat_registry::builder make_json_builder( : "")); } - // Validate the props bindings now rather than on first use, so a typo is - // reported when the model is registered rather than mid-analysis. + // Validate the bindings now rather than on first use, so a typo is reported + // when the model is registered rather than mid-analysis. BOTH halves of the + // target: a check that stops at the material name is the more dangerous kind, + // because it 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"); - 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) + const auto target = entry.get(); + + // One host constant per target. Repeating one makes the later slot + // overwrite the earlier, so an earlier constant is dropped and whatever + // it should have bound keeps its placeholder — silently. + 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) { - static std::once_flag once; - std::call_once(once, [] { - register_default_materials(); - register_umat_materials(); - }); + ensure_materials_registered(); if (props.size() < bindings.size()) throw fatal_error( diff --git a/tests/test_json_model.cpp b/tests/test_json_model.cpp index c476bd7..f86ef20 100644 --- a/tests/test_json_model.cpp +++ b/tests/test_json_model.cpp @@ -144,6 +144,59 @@ TEST(JsonModel, RejectsAConstantsEntryThatIsNotQualified) { EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); } +/// The other half of the target. nlohmann::json CREATES a missing key rather +/// than failing, so a misspelled parameter would be written where nothing reads +/// it while the real one kept the document's placeholder: with K=7 as the +/// placeholder and the deck supplying 250, the tangent came back +/// 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 correctly spelled one still registers, so the check is not simply + // rejecting everything. + const char* good = R"({ + "materials": [ + {"type": "constant_scalar", "name": "K", "value": 7.0} + ], + "constants": ["K::value"] + })"; + EXPECT_NO_THROW(u::make_json_builder(good)); +} + +/// Repeating a target makes the later host constant overwrite the earlier, so +/// one deck value is dropped and whatever it should have bound keeps 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 the same MATERIAL but different parameters is + // legitimate, so the key has to be the whole target. + const char* two_params = R"({ + "materials": [ + {"type": "isotropic_tangent", "name": "stiffness", + "K_source": "K", "G_source": "G"}, + {"type": "constant_scalar", "name": "K", "value": 0}, + {"type": "constant_scalar", "name": "G", "value": 0} + ], + "constants": ["stiffness::K_property", "stiffness::G_property"] + })"; + EXPECT_NO_THROW(u::make_json_builder(two_params)); +} + /// 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. From 8f7c16c5293b51ebfc775ead781434782e372393 Mon Sep 17 00:00:00 2001 From: petlenz Date: Mon, 17 Aug 2026 23:07:55 +0200 Subject: [PATCH 3/7] umat: shorten the comments --- include/numsim-materials/umat/json_model.h | 89 +++++++++------------- tests/test_json_model.cpp | 49 +++++------- 2 files changed, 56 insertions(+), 82 deletions(-) diff --git a/include/numsim-materials/umat/json_model.h b/include/numsim-materials/umat/json_model.h index 777b6b0..0a514c0 100644 --- a/include/numsim-materials/umat/json_model.h +++ b/include/numsim-materials/umat/json_model.h @@ -17,17 +17,14 @@ /// 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. +/// A builder written as a lambda means rebuilding the shared library for every +/// new material. This turns a document into the same builder the registry +/// takes, so a new material is a config edit. /// -/// 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=). +/// The document is io/json_material_factory's, plus an optional "constants" +/// array binding the deck's *USER MATERIAL constants to named parameters. +/// Spelled "constants", not "props": a PROPERTY here is a graph node, and it is +/// what the deck calls them (*USER MATERIAL, CONSTANTS=). /// /// { /// "materials": [ @@ -43,22 +40,17 @@ /// } /// /// 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. +/// "material::parameter" — the library's existing qualified-name syntax +/// ("time::state"), parsed by connection_source::parse. The right-hand side is +/// a PARAMETER, 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. +/// Values in the document are placeholders for anything listed there. Paired +/// with constant_scalar, a deck constant enters as a graph property, so +/// consumers are ordered after 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. +/// 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(); @@ -68,11 +60,10 @@ void register_umat_materials() { "external_scalar_source"); } -/// Register the materials a document may name, once per Traits. +/// The materials a document may name, registered once per Traits. /// -/// Hoisted out of the builder so it also runs at REGISTRATION time: the -/// binding targets are checked against each material's declared parameters, -/// and that needs a populated factory. Registering types is idempotent. +/// 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; @@ -94,11 +85,9 @@ inline std::string bound_parameter(const nlohmann::json& /*material*/, /// Reject a target naming a parameter the material does not declare. /// -/// Checking only that the MATERIAL exists leaves the other half of the target -/// unvalidated, and nlohmann::json CREATES a missing key rather than failing — -/// so a misspelled parameter is written to a key nothing reads while the real -/// one keeps the document's placeholder. The result is a wrong-but-plausible -/// modulus behind a stderr warning, in a job that reports no error at all. +/// 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, @@ -110,9 +99,8 @@ void require_declared_parameter(const nlohmann::json& material, const auto type = material["type"].get(); auto& factory = object_store::factory_type::instance(); - // A type the factory does not know is caught when the graph is built. Not - // failing here keeps a document free to name a material the caller registers - // after this one. + // 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; const auto wanted = bound_parameter(material, binding); @@ -145,10 +133,9 @@ inline connection_source parse_constant_target(const std::string& target) { /// 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. +/// 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) { @@ -163,11 +150,10 @@ typename umat_registry::builder make_json_builder( 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. + // 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( @@ -178,10 +164,9 @@ typename umat_registry::builder make_json_builder( : "")); } - // Validate the bindings now rather than on first use, so a typo is reported - // when the model is registered rather than mid-analysis. BOTH halves of the - // target: a check that stops at the material name is the more dangerous kind, - // because it reads as though the whole thing were verified. + // 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()) @@ -195,9 +180,8 @@ typename umat_registry::builder make_json_builder( "json_model: every \"constants\" entry must be a string"); const auto target = entry.get(); - // One host constant per target. Repeating one makes the later slot - // overwrite the earlier, so an earlier constant is dropped and whatever - // it should have bound keeps its placeholder — silently. + // 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 " @@ -230,8 +214,7 @@ typename umat_registry::builder make_json_builder( 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. + // 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"]) diff --git a/tests/test_json_model.cpp b/tests/test_json_model.cpp index f86ef20..050a5f6 100644 --- a/tests/test_json_model.cpp +++ b/tests/test_json_model.cpp @@ -18,8 +18,7 @@ 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. +/// Moduli bound to the deck's constants; nothing here is compiled. const char* kElastic = R"({ "materials": [ {"type": "external_strain_source", "name": "strain_in"}, @@ -88,8 +87,8 @@ 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. +/// 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}; @@ -119,9 +118,8 @@ TEST(JsonModel, RejectsAMalformedDocument) { 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. +/// 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}], @@ -144,12 +142,10 @@ TEST(JsonModel, RejectsAConstantsEntryThatIsNotQualified) { EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); } -/// The other half of the target. nlohmann::json CREATES a missing key rather -/// than failing, so a misspelled parameter would be written where nothing reads -/// it while the real one kept the document's placeholder: with K=7 as the -/// placeholder and the deck supplying 250, the tangent came back -/// 7 + 4(90)/3 = 127 instead of 370, behind nothing louder than a stderr -/// warning. +/// 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": [ @@ -159,8 +155,7 @@ TEST(JsonModel, RejectsAConstantsEntryNamingAnUndeclaredParameter) { })"; EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); - // The correctly spelled one still registers, so the check is not simply - // rejecting everything. + // The correct spelling still registers. const char* good = R"({ "materials": [ {"type": "constant_scalar", "name": "K", "value": 7.0} @@ -170,9 +165,9 @@ TEST(JsonModel, RejectsAConstantsEntryNamingAnUndeclaredParameter) { EXPECT_NO_THROW(u::make_json_builder(good)); } -/// Repeating a target makes the later host constant overwrite the earlier, so -/// one deck value is dropped and whatever it should have bound keeps its -/// placeholder — a zero modulus, in the case that prompted this. +/// 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": [ @@ -183,8 +178,8 @@ TEST(JsonModel, RejectsADuplicateConstantsTarget) { })"; EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); - // Two entries against the same MATERIAL but different parameters is - // legitimate, so the key has to be the whole target. + // Two entries against one material with different parameters is legitimate, + // so the key is the whole target. const char* two_params = R"({ "materials": [ {"type": "isotropic_tangent", "name": "stiffness", @@ -197,9 +192,8 @@ TEST(JsonModel, RejectsADuplicateConstantsTarget) { EXPECT_NO_THROW(u::make_json_builder(two_params)); } -/// 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. +/// 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}], @@ -208,13 +202,10 @@ TEST(JsonModel, RejectsAConstantsEntryTargetingAnUndefinedMaterial) { EXPECT_THROW(u::make_json_builder(doc), u::fatal_error); } -// One material NAME carries one PROPS array, and supplying different constants -// for a name whose graph is already built is fatal. That belongs to the -// registry rather than to this layer, so it is tested against the registry -// directly — see UmatInterface.ChangingPropsValuesForTheSameNameIsFatal. +// Changing the constants for one material name is fatal, but that belongs to +// the registry — see UmatInterface.ChangingPropsValuesForTheSameNameIsFatal. -/// Fewer constants than the document binds is a *DEPVAR-style setup error, and -/// must be fatal rather than a cutback. +/// 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"); From d56d102df502ecd8ce8b9d7dbcfb1deaf0ad927f Mon Sep 17 00:00:00 2001 From: petlenz Date: Mon, 17 Aug 2026 23:22:52 +0200 Subject: [PATCH 4/7] umat: trim the json_model header --- include/numsim-materials/umat/json_model.h | 26 +++++++++------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/include/numsim-materials/umat/json_model.h b/include/numsim-materials/umat/json_model.h index 0a514c0..f54c643 100644 --- a/include/numsim-materials/umat/json_model.h +++ b/include/numsim-materials/umat/json_model.h @@ -15,16 +15,11 @@ #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++. +/// 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. /// -/// A builder written as a lambda means rebuilding the shared library for every -/// new material. This turns a document into the same builder the registry -/// takes, so a new material is a config edit. -/// -/// The document is io/json_material_factory's, plus an optional "constants" -/// array binding the deck's *USER MATERIAL constants to named parameters. -/// Spelled "constants", not "props": a PROPERTY here is a graph node, and it is -/// what the deck calls them (*USER MATERIAL, CONSTANTS=). +/// io/json_material_factory's document, plus an optional "constants" array +/// binding the deck's *USER MATERIAL constants to named parameters: /// /// { /// "materials": [ @@ -39,14 +34,13 @@ /// "constants": ["K::value", "G::value"] /// } /// -/// PROPS[i] replaces the parameter named by constants[i], written -/// "material::parameter" — the library's existing qualified-name syntax -/// ("time::state"), parsed by connection_source::parse. The right-hand side is -/// a PARAMETER, not a property. +/// 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. /// -/// Values in the document are placeholders for anything listed there. Paired -/// with constant_scalar, a deck constant enters as a graph property, so -/// consumers are ordered after it — see materials/isotropic_tangent.h. +/// 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 From c11952e05bdb2f10081834b4788cc7033d761494 Mon Sep 17 00:00:00 2001 From: petlenz Date: Mon, 17 Aug 2026 23:38:02 +0200 Subject: [PATCH 5/7] umat: a constants target must name a NUMERIC parameter Checking only that the parameter is declared accepts targets that can never work: every material declares "name", and most declare *_source strings. A document binding "K::name" was accepted at registration and failed on the first UMAT call with a raw nlohmann type error -- deferred past the point this validation exists to precede, and saying nothing about decks. The declared parameter's type must now accept a number, and the error lists the ones that do. Caught a bad example in the duplicate-target test on the way: it used "stiffness::K_property" as a legitimate second binding, but those are string parameters and binding a constant to one was never meaningful. Replaced with linear_elasticity's K and G. --- include/numsim-materials/umat/json_model.h | 38 +++++++++++++++++----- tests/test_json_model.cpp | 31 +++++++++++++++--- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/include/numsim-materials/umat/json_model.h b/include/numsim-materials/umat/json_model.h index f54c643..e8be7d4 100644 --- a/include/numsim-materials/umat/json_model.h +++ b/include/numsim-materials/umat/json_model.h @@ -2,9 +2,11 @@ #define NUMSIM_MATERIALS_UMAT_JSON_MODEL_H #include +#include #include #include #include +#include #include #include @@ -97,18 +99,36 @@ void require_declared_parameter(const nlohmann::json& material, // 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); - std::vector declared; - for (const auto& [key, unused] : factory.schema(type)) declared.push_back(key); - if (std::find(declared.begin(), declared.end(), wanted) != declared.end()) - return; + 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; - std::sort(declared.begin(), declared.end()); - for (const auto& key : declared) known += (known.empty() ? "" : ", ") + key; - throw fatal_error("json_model: constants entry '" + target + - "' names parameter '" + wanted + "', which " + type + - " does not declare — it takes: " + 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, diff --git a/tests/test_json_model.cpp b/tests/test_json_model.cpp index 050a5f6..0152ae6 100644 --- a/tests/test_json_model.cpp +++ b/tests/test_json_model.cpp @@ -165,6 +165,28 @@ TEST(JsonModel, RejectsAConstantsEntryNamingAnUndeclaredParameter) { 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. @@ -182,12 +204,11 @@ TEST(JsonModel, RejectsADuplicateConstantsTarget) { // so the key is the whole target. const char* two_params = R"({ "materials": [ - {"type": "isotropic_tangent", "name": "stiffness", - "K_source": "K", "G_source": "G"}, - {"type": "constant_scalar", "name": "K", "value": 0}, - {"type": "constant_scalar", "name": "G", "value": 0} + {"type": "external_strain_source", "name": "strain_in"}, + {"type": "linear_elasticity", "name": "el", + "strain_producer_name": "strain_in", "K": 0, "G": 0} ], - "constants": ["stiffness::K_property", "stiffness::G_property"] + "constants": ["el::K", "el::G"] })"; EXPECT_NO_THROW(u::make_json_builder(two_params)); } From b6247004dbe3d0903c14b8beac75c7f8e48d5386 Mon Sep 17 00:00:00 2001 From: petlenz Date: Tue, 18 Aug 2026 21:33:55 +0200 Subject: [PATCH 6/7] cmake: do not pull fetched nlohmann/json into the install export set Linking nlohmann_json::nlohmann_json unconditionally works only when the package is FOUND installed. On the FetchContent path the target is not in any export set, so install(EXPORT) fails at configure: CMake Error: install(EXPORT "numsim-materialsTargets" ...) includes target "numsim-materials" which requires target "nlohmann_json" that is not in any export set. The repo already had the answer, three lines above, in tmech's handling: add the include path instead of linking, precisely because linking pulls it into the export set. nlohmann now gets the same treatment when fetched, and the plain link when found. Local builds could not surface this -- find_package succeeds on a machine that has the package, so the fetch path never ran here. Reproduced with -DCMAKE_DISABLE_FIND_PACKAGE_nlohmann_json=ON: 199/199. --- CMakeLists.txt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6556295..6e6539c 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,7 +94,19 @@ target_include_directories(${PROJECT_NAME} ) target_compile_features(${PROJECT_NAME} INTERFACE cxx_std_23) -target_link_libraries(${PROJECT_NAME} INTERFACE numsim-core nlohmann_json::nlohmann_json) +target_link_libraries(${PROJECT_NAME} INTERFACE 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) +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) From 3bac3cc8e884f79d4d21223b28d0e55eae9924bf Mon Sep 17 00:00:00 2001 From: petlenz Date: Sun, 23 Aug 2026 00:56:34 +0200 Subject: [PATCH 7/7] cmake: re-find nlohmann/json in the exported package config too The dependency list added with the Config fix covers numsim-core, tmech and Eigen3, but not nlohmann/json, whose INTERFACE link is added here. On a machine where nlohmann is FOUND rather than fetched it lands in the exported target set and a consumer of the installed package fails: The link interface of target "numsim-materials::numsim-materials" contains: nlohmann_json::nlohmann_json but the target was not found. Only reachable on a branch carrying both changes, which is why the original fix looked complete where it was written. --- CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3fa0ff8..f4823da 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,6 +113,11 @@ set(NUMSIM_MATERIALS_EXPORTED_DEPS numsim-core) # 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)