Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a06cd60
umat: define models in JSON, with the deck's constants bound into the…
petlenz Aug 16, 2026
1c1b2c6
umat: validate both halves of a constants target, and reject duplicates
petlenz Aug 17, 2026
bc2fbc1
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 17, 2026
8f7c16c
umat: shorten the comments
petlenz Aug 17, 2026
d56d102
umat: trim the json_model header
petlenz Aug 17, 2026
c11952e
umat: a constants target must name a NUMERIC parameter
petlenz Aug 17, 2026
6aa29a8
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 17, 2026
64d0e51
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 18, 2026
b624700
cmake: do not pull fetched nlohmann/json into the install export set
petlenz Aug 18, 2026
7e880b3
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 18, 2026
5fef8d9
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 18, 2026
d2ddbb2
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 18, 2026
077aaf3
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 18, 2026
13a8a1d
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 18, 2026
82edbdc
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 19, 2026
93eab01
Merge branch 'feature/elastic-stiffness-material' into feature/json-m…
petlenz Aug 22, 2026
3bac3cc
cmake: re-find nlohmann/json in the exported package config too
petlenz Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
258 changes: 258 additions & 0 deletions include/numsim-materials/umat/json_model.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
#ifndef NUMSIM_MATERIALS_UMAT_JSON_MODEL_H
#define NUMSIM_MATERIALS_UMAT_JSON_MODEL_H

#include <algorithm>
#include <cstddef>
#include <mutex>
#include <span>
#include <string>
#include <typeindex>
#include <vector>

#include <nlohmann/json.hpp>
#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 <typename Traits>
void register_umat_materials() {
auto& factory = material_factory<Traits>::instance();
factory.template register_type<external_strain_source<Traits>>(
"external_strain_source");
factory.template register_type<external_scalar_source<Traits>>(
"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 <typename Traits>
void ensure_materials_registered() {
static std::once_flag once;
std::call_once(once, [] {
register_default_materials<Traits>();
register_umat_materials<Traits>();
});
}

/// 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 <typename Traits>
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<std::string>();
auto& factory = object_store<Traits>::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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check verifies the parameter is DECLARED, not that it can meaningfully take a number.

"name" is declared by every material, via base::parameters(). So this passes:

"constants": ["K::name", "G::value"]
registration: ACCEPTED
FATAL: building the material graph for 'NAMEBIND' failed:
       [json.exception.type_error.302] type must be string, but is number

Two things wrong with that, neither fatal:

  1. It is deferred to the first UMAT call, which is what this validation exists to avoid — the whole point of checking at registration is that a config fault surfaces before an analysis starts.
  2. The message is a raw nlohmann type error. It says a string was expected where a number arrived; it does not say a host constant cannot be bound to a material's name.

Cheapest fix is rejecting "name" by name, since it is the only universally-declared parameter and binding a deck constant to it is never meaningful. The more general fix is checking the declared parameter's type accepts a double, which the schema's type_index would support — worth it only if other non-numeric parameters turn up as plausible targets.

Low severity: it fails loudly and you have to write something odd to reach it. Flagging it because it is a hole in a check I added specifically to close holes of this shape.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — and generalised past the case I reported.

Rejecting "name" alone would not have closed the class: most materials also declare *_source strings, and binding a constant to one of those fails the same way. The check now requires the declared parameter's type to accept a number, and the error lists the ones that do:

constants entry 'K::name' names 'name', which constant_scalar does not take
as a number — a host constant can bind: value

The new check immediately caught a bad example in my own test. RejectsADuplicateConstantsTarget used ["stiffness::K_property", "stiffness::G_property"] as the legitimate two-entries-one-material case — but those are string parameters, so binding numbers to them was never meaningful. Replaced with linear_elasticity's K and G, which really are numeric.

199 tests.

const auto schema = factory.schema(type);
std::vector<std::string> 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 Traits>
typename umat_registry<Traits>::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<connection_source> 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<Traits>();
std::vector<std::string> 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<std::string>();

// 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<std::string>() == binding.material)
owner = &m;
if (!owner)
throw fatal_error("json_model: constants entry targets material '" +
binding.material +
"', which the document does not define");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 2 (medium): a duplicated target silently drops a deck constant.

Nothing rejects the same target twice. "constants": ["K::value", "K::value"] assigns props[0] then props[1] to the same key — last write wins, props[0] is discarded, and G is never bound at all:

(B) accepted. C1111=90.0000  fatals=0

90 = props[1] + 0: K became 90, and G stayed at its placeholder of 0 — a zero shear modulus, silently.

This is a document-authoring mistake rather than a library bug, but it is exactly the class this PR decided to make loud (see the "props" rejection), and it is a two-line check next to the existing known test:

if (!seen.insert(entry.get<std::string>()).second)
  throw fatal_error("json_model: constants entry '" + target +
                    "' appears twice; each host constant binds one target");

Worth catching the near-miss too — two entries targeting the same MATERIAL with different parameters is legitimate, so the key has to be the full material::parameter, not just the material.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Duplicate targets are rejected at registration:

json_model: constants entry 'K::value' appears twice; each host constant binds
one target, and a repeat silently drops the earlier one

The key is the whole material::parameter target, as you flagged — the second test registers ["stiffness::K_property", "stiffness::G_property"] to pin that two entries against one material stay legal.

require_declared_parameter<Traits>(*owner, binding, target);
bindings.push_back(std::move(binding));
}
}

return [parsed, bindings](material_context<Traits>& ctx,
std::span<const double> props) {
ensure_materials_registered<Traits>();

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<std::string>() == bindings[i].material)
material[bindings[i].property] = props[i];

for (const auto& material : doc["materials"])
create_from_json<Traits>(ctx, material);
ctx.finalize();
};
}

/// Register a model defined by a JSON document.
template <typename Traits>
void register_json_model(
std::string cmname, const std::string& document,
typename umat_registry<Traits>::config cfg,
typename plane_stress_evaluator<Traits>::options ps_opts = {}) {
umat_registry<Traits>::instance().register_model(
std::move(cmname), make_json_builder<Traits>(document), std::move(cfg),
ps_opts);
}

} // namespace numsim::materials::umat

#endif // NUMSIM_MATERIALS_UMAT_JSON_MODEL_H
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading