Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions examples/moose_linear_elastic.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ int main() {
ConstitutiveModel model("LinearElasticShear");

auto mu = model.add_parameter("mu", 0.5, "Shear modulus");
auto eps = model.add_tensor_input("eps", 3, 2, InputRole::Strain);
auto eps = model.add_tensor_input("eps", 3, 2, roles::Strain);

auto sigma = 2 * mu * eps;
model.add_output("stress", sigma, OutputRole::Stress);
model.add_output("stress", sigma, roles::Stress);

MooseMaterialTarget target("ConstitutiveApp");
for (auto const &file : target.emit(model)) {
Expand Down
143 changes: 106 additions & 37 deletions include/numsim_codegen/recipe.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,69 @@
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <variant>
#include <vector>

namespace numsim::codegen {

// Semantic tag indicating what role an input plays in the constitutive
// model. Backends interpret these to wire the recipe to framework-specific
// inputs — Strain becomes MaterialProperty<RankTwoTensor>("strain") in
// MOOSE, the STRAN array in Abaqus UMAT, etc.
enum class InputRole {
Strain, // ε — primary driving variable
StrainIncrement, // Δε — Abaqus DSTRAN, viscoplastic increment
DeformationGradient,// F — finite-strain models
Stress, // σ — for stress-driven formulations
Temperature, // T
History, // a state variable carried from the previous step
Other, // generic input the backend treats as a coupled var
};
// Semantic role describing what an input or output represents in a
// constitutive model. Carries a name (used for documentation and identity
// comparison) plus structured attributes that backends switch on to decide
// how to wire the symbol to the framework — MOOSE's stateful-pair handling
// triggers on `is_stateful`, Abaqus's symmetric-storage encoding triggers
// on `is_symmetric` etc.
//
// The `roles::` namespace below ships a catalogue of common roles. Users
// can construct custom Role values directly (e.g.
// `Role{.name="phase_field", .is_driving=true, .expected_rank=0}`)
// without library changes — backends route them by attribute, not identity.
//
// Equality is name-based: two Role values with the same name compare equal
// regardless of attribute differences. Constructing a Role that shares its
// name with a `roles::` catalogue entry but carries different attributes
// throws at `add_*` time (see `ConstitutiveModel::validate_role_attributes`)
// — silent attribute mismatch would otherwise mis-route through
// `find_*_by_role`.
//
// `name` is held by value (`std::string`) so users may construct a Role
// from a temporary or a local string without lifetime hazards.
struct Role {
std::string name;
bool is_stateful = false; // requires old/new pair (state variable)
bool is_driving = false; // primary kinematic/kinetic input
bool is_symmetric = false; // symmetric rank-2 tensor
std::optional<std::size_t> expected_rank = std::nullopt;

// Semantic tag for outputs. Backends route these to the right framework
// sink — Stress to _stress[_qp] in MOOSE / STRESS in Abaqus UMAT, etc.
enum class OutputRole {
Stress, // σ
ConsistentTangent, // dσ/dε
HistoryNew, // updated state variable
Dissipation, // for dissipation-checking
Other,
friend auto operator==(Role const &a, Role const &b) -> bool {
return a.name == b.name;
}
};

// Catalogue of common roles. Backends recognise these by attribute, not
// identity, so user-defined roles flow through correctly as long as their
// attributes are set appropriately. `inline const` (not `constexpr`)
// because `Role` contains `std::string`, which is not a portable literal
// type — `inline` still gives single-definition-across-TUs.
//
// NOTE: these globals are dynamically initialised (std::string member).
// Calling `ConstitutiveModel::add_*` from another TU's static-init phase
// is unsafe — the catalogue may not yet be constructed. Build recipes
// at runtime (inside `main`, in a function), not at namespace scope as
// `static ConstitutiveModel g_model("...");`. Tracked for hardening if a
// real consumer ever hits the dynamic-init-order trap.
namespace roles {
inline const Role Strain {"strain", false, true, true, 2};
inline const Role StrainIncrement {"strain_increment", false, true, true, 2};
inline const Role DeformationGradient {"deformation_gradient", false, true, false, 2};
inline const Role Stress {"stress", false, false, true, 2};
inline const Role ConsistentTangent {"consistent_tangent", false, false, false, 4};
inline const Role Temperature {"temperature", false, true, false, 0};
inline const Role History {"history", true, false, false};
inline const Role Dissipation {"dissipation", false, false, false, 0};
inline const Role Other {"other"};
} // namespace roles

// Declaration of an external symbol (input, parameter). The codegen treats
// inputs and parameters the same on the scalar/tensor level (both are
// read-only) but backends distinguish them — parameters come from MOOSE
Expand All @@ -61,7 +95,7 @@ struct SymbolDecl {
std::size_t rank = 0; // tensor only
std::optional<double> default_value; // parameter only
std::string doc;
InputRole role = InputRole::Other; // semantic tag; Other for parameters
Role role = roles::Other; // semantic tag; Other for parameters
};

// Declaration of a computed output that the generated function emits.
Expand All @@ -76,7 +110,7 @@ struct OutputDecl {
expr;
std::size_t dim = 0;
std::size_t rank = 0;
OutputRole role = OutputRole::Other;
Role role = roles::Other;
};

// The constitutive-model registry. Holds declared inputs, parameters,
Expand All @@ -91,27 +125,29 @@ class ConstitutiveModel {

// ─── Input declarations ─────────────────────────────────────────

auto add_scalar_input(std::string name, InputRole role = InputRole::Other)
auto add_scalar_input(std::string name, Role role = roles::Other)
-> cas::expression_holder<cas::scalar_expression> {
validate_role_attributes(role);
auto var = cas::make_expression<cas::scalar>(name);
SymbolDecl decl{name, SymbolDecl::Category::Input,
SymbolDecl::Kind::Scalar};
decl.role = role;
m_symbols.push_back(decl);
m_inputs_cache.push_back(decl);
decl.role = std::move(role);
m_inputs_cache.push_back(decl); // copy
m_symbols.push_back(std::move(decl)); // move (no copy)
m_scalar_symbols.emplace_back(name, var);
return var;
}

auto add_tensor_input(std::string name, std::size_t dim, std::size_t rank,
InputRole role = InputRole::Other)
Role role = roles::Other)
-> cas::expression_holder<cas::tensor_expression> {
validate_role_attributes(role);
auto var = cas::make_expression<cas::tensor>(name, dim, rank);
SymbolDecl decl{name, SymbolDecl::Category::Input,
SymbolDecl::Kind::Tensor, dim, rank};
decl.role = role;
m_symbols.push_back(decl);
decl.role = std::move(role);
m_inputs_cache.push_back(decl);
m_symbols.push_back(std::move(decl));
m_tensor_symbols.emplace_back(name, var);
return var;
}
Expand All @@ -126,8 +162,8 @@ class ConstitutiveModel {
SymbolDecl::Kind::Scalar};
decl.default_value = default_value;
decl.doc = std::move(doc);
m_symbols.push_back(decl);
m_parameters_cache.push_back(decl);
m_symbols.push_back(std::move(decl));
m_scalar_symbols.emplace_back(name, var);
return var;
}
Expand All @@ -136,16 +172,19 @@ class ConstitutiveModel {

void add_output(std::string name,
cas::expression_holder<cas::scalar_expression> expr,
OutputRole role = OutputRole::Other) {
OutputDecl decl{name, OutputDecl::Kind::Scalar, expr, 0, 0, role};
Role role = roles::Other) {
validate_role_attributes(role);
OutputDecl decl{name, OutputDecl::Kind::Scalar, expr, 0, 0,
std::move(role)};
m_outputs.push_back(std::move(decl));
}

void add_output(std::string name,
cas::expression_holder<cas::tensor_expression> expr,
OutputRole role = OutputRole::Other) {
Role role = roles::Other) {
validate_role_attributes(role);
OutputDecl decl{name, OutputDecl::Kind::Tensor, expr, expr.get().dim(),
expr.get().rank(), role};
expr.get().rank(), std::move(role)};
m_outputs.push_back(std::move(decl));
}

Expand Down Expand Up @@ -251,7 +290,7 @@ class ConstitutiveModel {
}

// Helpers for backends to find specific roles.
[[nodiscard]] auto find_input_by_role(InputRole role) const
[[nodiscard]] auto find_input_by_role(Role const &role) const
-> SymbolDecl const * {
for (auto const &s : m_symbols) {
if (s.category == SymbolDecl::Category::Input && s.role == role) {
Expand All @@ -261,7 +300,7 @@ class ConstitutiveModel {
return nullptr;
}

[[nodiscard]] auto find_output_by_role(OutputRole role) const
[[nodiscard]] auto find_output_by_role(Role const &role) const
-> OutputDecl const * {
for (auto const &o : m_outputs) {
if (o.role == role) {
Expand All @@ -284,6 +323,36 @@ class ConstitutiveModel {
}

private:
// Reject a user-defined Role that shares a name with a `roles::` catalogue
// entry but carries different attribute values. Name-only equality means
// such a mismatch would otherwise silently mis-route through
// find_*_by_role and confuse backends. Called from every `add_*` so the
// error surfaces at the offending call, not at emit time.
static void validate_role_attributes(Role const &r) {
auto check = [&](Role const &canon, char const *constant_name) {
if (r.name != canon.name) return;
if (r.is_stateful != canon.is_stateful
|| r.is_driving != canon.is_driving
|| r.is_symmetric != canon.is_symmetric
|| r.expected_rank != canon.expected_rank) {
throw std::runtime_error(
"Role '" + r.name + "' shares its name with predefined " +
constant_name + " but carries different attribute values. " +
"Either use the predefined constant directly, or pick a different "
"name for the custom role.");
}
};
check(roles::Strain, "roles::Strain");
check(roles::StrainIncrement, "roles::StrainIncrement");
check(roles::DeformationGradient, "roles::DeformationGradient");
check(roles::Stress, "roles::Stress");
check(roles::ConsistentTangent, "roles::ConsistentTangent");
check(roles::Temperature, "roles::Temperature");
check(roles::History, "roles::History");
check(roles::Dissipation, "roles::Dissipation");
check(roles::Other, "roles::Other");
}

[[nodiscard]] auto render_compute_function(
CodeGenContext const &ctx,
std::vector<std::string> const &output_rhs) const -> std::string {
Expand Down
58 changes: 41 additions & 17 deletions include/numsim_codegen/targets/moose_material.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,31 @@ namespace numsim::codegen {
// underlying storage is contiguous row-major doubles, which matches
// MOOSE's internal layout for RankTwoTensorTempl<Real> / RankFourTensorTempl.
//
// Inputs tagged with InputRole::Strain become MaterialProperty<RankTwoTensor>
// reads. Outputs tagged with OutputRole::Stress become
// MaterialProperty<RankTwoTensor> writes. Other roles are mapped likewise.
// Inputs tagged with roles::Strain become MaterialProperty<RankTwoTensor>
// reads. Outputs tagged with roles::Stress become MaterialProperty<RankTwoTensor>
// writes. Role attributes (is_stateful, is_symmetric, ...) drive backend
// decisions — user-defined roles flow through transparently.
class MooseMaterialTarget : public Target {
public:
explicit MooseMaterialTarget(std::string app_name = "MyApp")
: m_app_name(std::move(app_name)) {}

[[nodiscard]] auto emit(ConstitutiveModel const &model) const
-> std::vector<EmittedFile> override {
// History symbols would need old/new MaterialProperty pair handling
// Stateful symbols would need old/new MaterialProperty pair handling
// that the MOOSE backend doesn't implement yet. Fail loudly rather
// than silently emit a regular read.
//
// NOTE: stateful *outputs* (is_stateful == true on an OutputDecl) are
// NOT guarded here. They are currently accepted silently and produce
// a regular non-stateful MaterialProperty write, which is semantically
// wrong for state-variable updates. Tracked in issue #15. When adding
// the output guard, mirror the pattern below.
for (auto const &i : model.inputs()) {
if (i.role == InputRole::History) {
if (i.role.is_stateful) {
throw std::runtime_error(
"MooseMaterialTarget: InputRole::History on input '" + i.name +
"MooseMaterialTarget: stateful role '" + i.role.name +
"' on input '" + i.name +
"' requires the History machinery (old/new MaterialProperty pair, "
"stateful initialisation) which is not implemented in this phase. "
"See the numsim-codegen Phase B roadmap.");
Expand Down Expand Up @@ -133,7 +141,8 @@ class MooseMaterialTarget : public Target {
}
for (auto const &i : model.inputs()) {
os << " params.addRequiredParam<MaterialPropertyName>(\"" << i.name
<< "\", \"Coupled " << input_role_doc(i.role) << "\");\n";
<< "\", \"Coupled " << escape_for_cpp_literal(i.role.name)
<< "\");\n";
}
os << " return params;\n";
os << "}\n\n";
Expand Down Expand Up @@ -262,17 +271,32 @@ class MooseMaterialTarget : public Target {
"rank 4 (RankFourTensor) are supported.");
}

static auto input_role_doc(InputRole role) -> std::string {
switch (role) {
case InputRole::Strain: return "strain tensor";
case InputRole::StrainIncrement: return "strain increment tensor";
case InputRole::DeformationGradient:return "deformation gradient";
case InputRole::Stress: return "stress tensor";
case InputRole::Temperature: return "temperature";
case InputRole::History: return "history variable";
case InputRole::Other: return "input";
// Escape a user-supplied string for safe inclusion in a C++ string
// literal. Handles the five characters that would otherwise produce
// ill-formed C++:
// `"` unescaped quote terminates the literal early
// `\\` unescaped backslash starts an unintended escape sequence
// `\n` raw newline in a string literal is forbidden by [lex.string]
// `\r` produces an embedded CR — implementation-defined behavior
// `\0` embeds a null byte in the string value, which silently
// truncates downstream C-string APIs
// Tabs and other control bytes pass through unchanged (ugly but legal).
// Cross-ref #14 → CORR-B4 for a future general-identifier validator
// that would reject non-identifier names at recipe-build time.
static auto escape_for_cpp_literal(std::string_view s) -> std::string {
std::string out;
out.reserve(s.size());
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\0': out += "\\0"; break;
default: out += c; break;
}
}
return "input";
return out;
}

std::string m_app_name;
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ add_executable(numsim_codegen_tests
ScalarCodeEmitTest.cpp
RecipeTest.cpp
MooseTargetTest.cpp
RoleFlexibilityTest.cpp
)

target_link_libraries(numsim_codegen_tests
Expand Down
10 changes: 5 additions & 5 deletions tests/MooseTargetTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ namespace {
auto build_linear_elastic_shear() -> ConstitutiveModel {
ConstitutiveModel m("LinearElasticShear");
auto mu = m.add_parameter("mu", 0.5, "Shear modulus");
auto eps = m.add_tensor_input("eps", 3, 2, InputRole::Strain);
m.add_output("stress", 2 * mu * eps, OutputRole::Stress);
auto eps = m.add_tensor_input("eps", 3, 2, roles::Strain);
m.add_output("stress", 2 * mu * eps, roles::Stress);
return m;
}
} // namespace
Expand Down Expand Up @@ -115,16 +115,16 @@ TEST(MooseTarget, ComputeQpUsesAdaptorAndCallsLayer2) {
}

TEST(MooseTarget, RecipeRolesDriveOutputMapping) {
// The OutputRole::Stress tag should make the corresponding output a
// The roles::Stress tag should make the corresponding output a
// RankTwoTensor MaterialProperty named consistently in the generated
// boilerplate. Test this end-to-end by checking the header members.
MooseMaterialTarget target;
auto m = build_linear_elastic_shear();
auto const *stress_decl = m.find_output_by_role(OutputRole::Stress);
auto const *stress_decl = m.find_output_by_role(roles::Stress);
ASSERT_NE(stress_decl, nullptr);
EXPECT_EQ(stress_decl->name, "stress");

auto const *strain_decl = m.find_input_by_role(InputRole::Strain);
auto const *strain_decl = m.find_input_by_role(roles::Strain);
ASSERT_NE(strain_decl, nullptr);
EXPECT_EQ(strain_decl->name, "eps");
}
Expand Down
Loading
Loading