diff --git a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md index 56bc83f..130c8ce 100644 --- a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md +++ b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md @@ -238,3 +238,91 @@ - handoff: backend-free `KeywordParameter`, `DataLine`, `KeywordBlock`, `ParsedInput`, and `AbaqusInputReader::read` with syntax/source provenance are available to Step 12 semantic mapping. + + +## Step 12 — inp-domain-mapping + +- task_id: `TASK-12` +- status: `completed` +- changed_files: `include/fesa/io/abaqus/domain_mapper.hpp`, + `src/fesa/io/abaqus/domain_mapper.cpp`, + `tests/unit/io/abaqus/domain_mapper_test.cpp`, + `src/fesa/CMakeLists.txt`, `tests/CMakeLists.txt`, + `docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md`, + `phases/linear-static-3d-euler-beam/index.json`, + `.superpowers/sdd/linear-static-3d-euler-beam/task-12-report.md` +- requirement_ids: `FESA-REQ-LS3DEB-001`, `FESA-REQ-LS3DEB-002`, + `FESA-REQ-LS3DEB-005` through `FESA-REQ-LS3DEB-020`, + `FESA-REQ-LS3DEB-034`, `FESA-REQ-LS3DEB-044` +- test_ids: `T12-MAP-001`, `T12-MAP-002`, `T12-MAP-003`, + `T12-MAP-004`, `T12-MAP-005` + +| stage | exact command | exit_code | expected_or_observed_result | evidence_tail | +| --- | --- | ---: | --- | --- | +| RED | `cmake --build .harness/build --config Debug --target fesa_tests`; `ctest --test-dir .harness/build -C Debug -R InpDomainMapping --output-on-failure` | 1 | All five planned tests were registered before production and the implementation-owned mapper API was absent | MSVC C1083 reported missing `fesa/io/abaqus/domain_mapper.hpp`; CTest then found no runnable mapper tests because the executable did not build | +| GREEN-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 0 | The mapper, five semantic tests, solver library, and unit executable compile and link | `domain_mapper.cpp` and `domain_mapper_test.cpp` built without a FESA warning under `/W4 /WX` | +| GREEN-test | `ctest --test-dir .harness/build -C Debug -R InpDomainMapping --output-on-failure` | 0 | Exact supported inventory, deterministic expansion, no-op policy, negative portfolio, and DLOAD exclusion pass | 5/5 exact `InpDomainMapping` tests passed | +| VERIFY-configure | `cmake -S . -B .harness/build -A x64 -DFESA_GTEST_SOURCE_DIR=C:/git/googletest "-DMKL_DIR=C:/Program Files (x86)/Intel/oneAPI/mkl/2026.1/lib/cmake/mkl" "-DTBB_DIR=C:/Program Files (x86)/Intel/oneAPI/tbb/2023.1/lib/cmake/tbb" "-DHDF5_DIR=C:/Program Files/HDF_Group/HDF5/2.1.1/cmake"` | 0 | Approved MSVC x64 build tree regenerates with explicit dependencies | Windows SDK and oneMKL 2026.1 resolved; configure and generate completed | +| VERIFY-build | `cmake --build .harness/build --config Debug` | 0 | Full Debug build passes without a new FESA warning | `fesa_solver.lib` and `fesa_unit_tests.exe` built under the existing `/W4 /WX` policy | +| VERIFY-targeted | `ctest --test-dir .harness/build -C Debug -R InpDomainMapping --output-on-failure` | 0 | Focused Step 12 suite remains green after review corrections | 5/5 `InpDomainMapping` tests passed | +| VERIFY-discovery | `ctest --test-dir .harness/build -C Debug --show-only=json-v1` | 0 | CTest discovers the accumulated suite and all five exact mapper tests | 19 tests discovered with all five planned `InpDomainMapping` names and feature/unit labels | +| VERIFY-full | `ctest --test-dir .harness/build -C Debug --output-on-failure` | 0 | Full accumulated C++ suite has zero failures | 19/19 tests passed | +| VERIFY-dependency-direction | Backend, upward-I/O, core-upward, distributed-load-object, unapproved-`nu`-gate, and exact-test-count scans over Step 12 files | 0 | Public dependencies and approved scope remain intact | backend leaks 0; upward dependencies 0; core upward dependencies 0; distributed-load objects 0; unapproved `nu` gates 0; mapper tests 5 | +| VERIFY-diff | `git diff --check` plus trailing-whitespace scan over the three new files | 0 | Tracked and untracked Step 12 files have no whitespace errors | Git reported informational LF-to-CRLF notices only; new-file trailing whitespace matches 0 | +| VERIFY-reference | `git diff --exit-code -- reference/`; `git status --short -- reference/` | 0 | Approved legacy reference artifacts remain read-only and unchanged | Reference diff exit 0 and reference status empty | + +- contract_checks: the mapper consumes syntax-only `ParsedInput` and returns + one complete immutable `Domain` or a categorized diagnostic. It enforces the + monotonic top-level/part/assembly/step grammar, B33 only, identity instances + only, exact inclusive set generation, case-insensitive references, stable + declaration/instance-order internal IDs, and `SourceEntityId + {instance_name, source_label}`. Direct node labels, part and assembly sets, + ambiguous multi-instance labels/sets, and direct-versus-set constraint + overlap are resolved before Domain success. +- contract_checks: material and section mapping uses exact `E,nu`, + `G=E/(2*(1+nu))`, `A,I11,I12,I22,J`, exact `I12=0`, + `Iy=I11`, `Iz=I22`, and `n1 -> local y`. It requires positive + `E,G,A,I11,I22,J` without adding a `nu < 0.5` gate. Nonfinite material, + section, and guide data are model failures. Overflow-safe scaled norms apply + the exact length and guide-vector inequalities; equality fails, values above + pass, and valid finite geometry near `1e308` is accepted without a fallback + axis. +- contract_checks: model-level and step-level boundaries and sole-step CLOAD + targets are validated, conflicting expanded node/DOF prescriptions fail, the + exact four positive finite `*STATIC` fields are retained, and the sole + canonical result identity is `Step-1`, frame 0. The exact warning no-op + allowlist produces stable `ignored-input-keyword` diagnostics without + changing semantic objects. B31, transforms, nested/dependent assembly + semantics, second step, NLGEOM, unknown keywords, and `*DLOAD` fail; no + distributed-load Domain object, B31 alias, multi-step path, or transform + fallback exists. +- generated_evidence: `.harness/build/src/fesa/Debug/fesa_solver.lib`, + `.harness/build/tests/Debug/fesa_unit_tests.exe` +- reference_diff: unchanged; the legacy cantilever input maps successfully + while exact bytes and modification time remain unchanged, and + `git diff --exit-code -- reference/` exits 0 +- handoff: `AbaqusDomainMapper::map(const ParsedInput&)`, complete immutable + Domain objects, stable expanded node/element/set identities, validated + material/section indices, and the sole `StaticStepDefinition` are available + to Step 13 `AnalysisModel`. + +### Step 12 Review Corrections + +- A duplicate-`*ELASTIC` regression test first failed because a second + consecutive elasticity record lost the owning-material state. Preserving the + immediate material eligibility produced the exact `duplicate-entity` + diagnostic and restored targeted 5/5. +- Ten monotonic-order regressions first failed at the accepted late-`*PART` + case. Explicit phase state now rejects late model data, assembly sets before + or followed by instances, element-before-node, incomplete block closure, and + pre-`*STATIC` or out-of-order history data with + `invalid-keyword-location`. +- Review edge tests then failed for valid `1e308` geometry and for nonfinite + elasticity being classified as input. Context-specific model parsing, + overflow-safe scaled geometry comparisons, active beam-section context, + exact GENERATE landing, direct/ambiguous target resolution, and exact + threshold cases were added. The corrected focused suite passes 5/5 and the + full accumulated suite passes 19/19. +- Focused re-review returned Critical 0 and Important 0. Its sole remaining + minor requested an explicit negative transverse-shear context assertion; + that table case was added and passes without a production change. diff --git a/include/fesa/io/abaqus/domain_mapper.hpp b/include/fesa/io/abaqus/domain_mapper.hpp new file mode 100644 index 0000000..8d2a6e4 --- /dev/null +++ b/include/fesa/io/abaqus/domain_mapper.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "fesa/core/status.hpp" +#include "fesa/io/abaqus/input_syntax.hpp" +#include "fesa/model/domain.hpp" + +namespace fesa { + +// Converts syntax-only blocks into the approved immutable B33 semantic model. +class AbaqusDomainMapper { +public: + Result map(const ParsedInput& input) const; +}; + +} // namespace fesa diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 955c141..b173ac6 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -4,6 +4,7 @@ add_library( build_info.cpp core/diagnostic.cpp core/status.cpp + io/abaqus/domain_mapper.cpp io/abaqus/input_reader.cpp math/matrix.cpp math/vector.cpp diff --git a/src/fesa/io/abaqus/domain_mapper.cpp b/src/fesa/io/abaqus/domain_mapper.cpp new file mode 100644 index 0000000..22e01ac --- /dev/null +++ b/src/fesa/io/abaqus/domain_mapper.cpp @@ -0,0 +1,2148 @@ +#include "fesa/io/abaqus/domain_mapper.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +std::string uppercaseAscii(std::string value) { + std::transform( + value.begin(), value.end(), value.begin(), [](char character) { + if (character >= 'a' && character <= 'z') { + return static_cast(character - 'a' + 'A'); + } + return character; + }); + return value; +} + +bool equalName(const std::string& left, const std::string& right) { + return uppercaseAscii(left) == uppercaseAscii(right); +} + +std::vector withoutTrailingEmpty( + std::vector fields) { + while (!fields.empty() && fields.back().empty()) { + fields.pop_back(); + } + return fields; +} + +struct RawNode { + std::int64_t label; + std::string labelText; + std::array coordinates; + SourceLocation location; +}; + +struct RawElement { + std::int64_t label; + std::string labelText; + std::array nodeLabels; + SourceLocation location; +}; + +struct RawSet { + std::string name; + std::vector members; + SourceLocation location; +}; + +struct RawSection { + std::string elementSetName; + std::string materialName; + std::array properties; + std::array firstAxis; + std::vector> sectionPoints; + SourceLocation location; +}; + +struct RawPart { + std::string name; + SourceLocation location; + std::vector nodes; + std::vector elements; + std::vector nodeSets; + std::vector elementSets; + std::vector sections; +}; + +struct RawInstance { + std::string name; + std::string partName; + SourceLocation location; +}; + +struct RawAssemblySet { + bool isNodeSet; + std::string name; + std::string instanceName; + std::vector members; + SourceLocation location; +}; + +struct RawMaterial { + std::string name; + double youngsModulus{0.0}; + double poissonRatio{0.0}; + bool hasElastic{false}; + SourceLocation location; + SourceLocation elasticLocation; +}; + +struct RawStep { + SourceLocation location; + bool hasStatic{false}; + std::array staticValues{}; + std::vector boundaries; + std::vector loads; +}; + +struct MappingFailure { + FailureCategory category; + Diagnostic diagnostic; +}; + +class MappingContext { +public: + explicit MappingContext(const ParsedInput& input) : input_{input} {} + + Result run() { + parseBlocks(); + if (!failure_) { + finalizeModel(); + } + if (failure_) { + return Result::failure(Status::failure( + failure_->category, {std::move(failure_->diagnostic)})); + } + sortDiagnostics(definition_.warnings); + return Domain::create(std::move(definition_)); + } + +private: + const KeywordParameter* parameter( + const KeywordBlock& block, + std::string_view name) const { + const auto found = std::find_if( + block.parameters.begin(), + block.parameters.end(), + [name](const KeywordParameter& candidate) { + return candidate.name == name; + }); + return found == block.parameters.end() ? nullptr : &*found; + } + + bool fail( + FailureCategory category, + std::string code, + const SourceLocation& location, + std::string keyword, + std::string entityIdentity, + std::string message) { + if (!failure_) { + failure_ = MappingFailure{ + category, + {Severity::error, + std::move(code), + location, + std::move(keyword), + std::move(entityIdentity), + std::move(message)}}; + } + return false; + } + + bool inputFailure( + std::string code, + const SourceLocation& location, + std::string keyword, + std::string entityIdentity, + std::string message) { + return fail( + FailureCategory::input, + std::move(code), + location, + std::move(keyword), + std::move(entityIdentity), + std::move(message)); + } + + bool modelFailure( + std::string code, + const SourceLocation& location, + std::string keyword, + std::string entityIdentity, + std::string message) { + return fail( + FailureCategory::model, + std::move(code), + location, + std::move(keyword), + std::move(entityIdentity), + std::move(message)); + } + + bool invalidKeywordLocation( + const KeywordBlock& block, + std::string message) { + return inputFailure( + "invalid-keyword-location", + block.location, + block.canonicalName, + "", + std::move(message)); + } + + bool validateParameters( + const KeywordBlock& block, + const std::vector& allowed) { + std::set seen; + for (const auto& candidate : block.parameters) { + if (!seen.insert(candidate.name).second) { + return inputFailure( + "duplicate-entity", + block.location, + block.canonicalName, + candidate.name, + "A keyword parameter may be declared only once."); + } + if (std::find(allowed.begin(), allowed.end(), candidate.name) == + allowed.end()) { + return inputFailure( + "invalid-keyword-parameter", + block.location, + block.canonicalName, + candidate.name, + "The keyword parameter is outside the approved subset."); + } + } + return true; + } + + const std::string* requiredParameterValue( + const KeywordBlock& block, + std::string_view name) { + const auto* found = parameter(block, name); + if (found == nullptr || !found->value || found->value->empty()) { + inputFailure( + "invalid-keyword-parameter", + block.location, + block.canonicalName, + std::string{name}, + "The keyword requires a non-empty parameter value."); + return nullptr; + } + return &*found->value; + } + + bool requireNoData(const KeywordBlock& block) { + if (!block.data.empty()) { + return inputFailure( + "invalid-data-arity", + block.data.front().location, + block.canonicalName, + "", + "This keyword does not accept data rows."); + } + return true; + } + + bool parseInteger( + const std::string& text, + std::int64_t& value, + const SourceLocation& location, + const std::string& keyword, + bool positive) { + if (text.empty()) { + return inputFailure( + "invalid-numeric-value", location, keyword, text, + "A numeric field cannot be empty."); + } + char* end = nullptr; + errno = 0; + const long long parsed = std::strtoll(text.c_str(), &end, 10); + if (errno == ERANGE || end != text.c_str() + text.size() || + (positive && parsed <= 0)) { + return inputFailure( + "invalid-numeric-value", location, keyword, text, + "The field must be a valid positive base-10 integer."); + } + value = static_cast(parsed); + return true; + } + + bool tryPositiveInteger(const std::string& text, std::int64_t& value) const { + if (text.empty()) { + return false; + } + char* end = nullptr; + errno = 0; + const long long parsed = std::strtoll(text.c_str(), &end, 10); + if (errno == ERANGE || end != text.c_str() + text.size() || parsed <= 0) { + return false; + } + value = static_cast(parsed); + return true; + } + + bool parseDouble( + const std::string& text, + double& value, + const SourceLocation& location, + const std::string& keyword) { + if (text.empty()) { + return inputFailure( + "invalid-numeric-value", location, keyword, text, + "A numeric field cannot be empty."); + } + char* end = nullptr; + errno = 0; + const double parsed = std::strtod(text.c_str(), &end); + if (errno == ERANGE || end != text.c_str() + text.size() || + !std::isfinite(parsed)) { + return inputFailure( + "invalid-numeric-value", location, keyword, text, + "The field must be a finite floating-point value."); + } + value = parsed; + return true; + } + + bool parseModelDouble( + const std::string& text, + double& value, + const SourceLocation& location, + const std::string& keyword, + const std::string& diagnosticCode, + const std::string& message) { + if (text.empty()) { + return inputFailure( + "invalid-numeric-value", location, keyword, text, + "A numeric field cannot be empty."); + } + char* end = nullptr; + errno = 0; + const double parsed = std::strtod(text.c_str(), &end); + if (end != text.c_str() + text.size()) { + return inputFailure( + "invalid-numeric-value", location, keyword, text, + "The field must use floating-point syntax."); + } + if (errno == ERANGE || !std::isfinite(parsed)) { + return modelFailure( + diagnosticCode, location, keyword, text, message); + } + value = parsed; + return true; + } + + bool containsName( + const std::vector& values, + const std::string& name) const { + return std::any_of( + values.begin(), values.end(), [&name](const RawPart& value) { + return equalName(value.name, name); + }); + } + + bool containsName( + const std::vector& values, + const std::string& name) const { + return std::any_of( + values.begin(), values.end(), [&name](const RawInstance& value) { + return equalName(value.name, name); + }); + } + + bool containsName( + const std::vector& values, + const std::string& name) const { + return std::any_of( + values.begin(), values.end(), [&name](const RawMaterial& value) { + return equalName(value.name, name); + }); + } + + bool containsSetName(const RawPart& part, const std::string& name) const { + const auto matches = [&name](const RawSet& set) { + return equalName(set.name, name); + }; + return std::any_of(part.nodeSets.begin(), part.nodeSets.end(), matches) || + std::any_of(part.elementSets.begin(), part.elementSets.end(), matches); + } + + bool containsAssemblySetName(const std::string& name) const { + return std::any_of( + assemblySets_.begin(), + assemblySets_.end(), + [&name](const RawAssemblySet& set) { + return equalName(set.name, name); + }); + } + + void parseBlocks() { + definition_.sourcePath = input_.sourcePath; + definition_.sourceContentIdentity = input_.sourceContentIdentity; + + for (std::size_t index = 0U; + index < input_.blocks.size() && !failure_; + ++index) { + const auto& block = input_.blocks[index]; + if (inInstance_) { + parseInstanceBlock(block); + } else if (currentPart_) { + parsePartBlock(block); + } else if (inAssembly_) { + parseAssemblyBlock(block); + } else if (inStep_) { + parseStepBlock(block); + } else { + parseTopLevelBlock(block, index); + } + } + + if (!failure_ && + (currentPart_ || inAssembly_ || inInstance_ || inStep_)) { + const auto location = input_.blocks.empty() + ? SourceLocation{input_.sourcePath, 0U} + : input_.blocks.back().location; + inputFailure( + "unclosed-keyword-block", + location, + "", + "", + "A part, assembly, instance, or step block was not closed."); + } + } + + void parseTopLevelBlock(const KeywordBlock& block, std::size_t index) { + if (stepSeen_) { + if (block.canonicalName == "STEP") { + parseStepStart(block); + } else { + invalidKeywordLocation( + block, + "The sole analysis step must be the final top-level block."); + } + return; + } + + if (block.canonicalName != "ELASTIC") { + materialEligible_.reset(); + } + pendingSection_.reset(); + activeOutput_ = false; + + if (block.canonicalName == "HEADING") { + if (index != 0U || headingSeen_ || + !validateParameters(block, {})) { + if (!failure_) { + inputFailure( + "invalid-keyword-location", + block.location, + block.canonicalName, + "", + "HEADING is optional only as the first keyword."); + } + return; + } + headingSeen_ = true; + for (std::size_t row = 0U; row < block.data.size(); ++row) { + if (row != 0U) { + definition_.heading.push_back('\n'); + } + for (std::size_t field = 0U; + field < block.data[row].fields.size(); + ++field) { + if (field != 0U) { + definition_.heading.push_back(','); + } + definition_.heading += block.data[row].fields[field]; + } + } + return; + } + if (block.canonicalName == "PREPRINT") { + if (!requireNoData(block)) { + return; + } + addIgnoredWarning(block); + return; + } + if (block.canonicalName == "PART") { + parsePartStart(block); + return; + } + if (block.canonicalName == "ASSEMBLY") { + parseAssemblyStart(block); + return; + } + if (block.canonicalName == "MATERIAL") { + parseMaterial(block); + return; + } + if (block.canonicalName == "ELASTIC") { + parseElastic(block); + return; + } + if (block.canonicalName == "BOUNDARY") { + if (!assemblySeen_ || materials_.empty()) { + invalidKeywordLocation( + block, + "Model boundary data follows the assembly and materials."); + return; + } + modelBoundarySeen_ = true; + parseBoundary(block, modelBoundaries_); + return; + } + if (block.canonicalName == "STEP") { + parseStepStart(block); + return; + } + if (block.canonicalName == "END PART" || + block.canonicalName == "END ASSEMBLY" || + block.canonicalName == "END INSTANCE" || + block.canonicalName == "END STEP") { + inputFailure( + "invalid-keyword-location", + block.location, + block.canonicalName, + "", + "The closing keyword has no matching open block."); + return; + } + rejectUnknown(block); + } + + void parsePartStart(const KeywordBlock& block) { + if (assemblySeen_ || !materials_.empty() || modelBoundarySeen_) { + invalidKeywordLocation( + block, + "All part blocks must precede the sole assembly block."); + return; + } + if (!validateParameters(block, {"NAME"}) || !requireNoData(block)) { + return; + } + const auto* name = requiredParameterValue(block, "NAME"); + if (name == nullptr) { + return; + } + if (containsName(parts_, *name)) { + inputFailure( + "duplicate-entity", + block.location, + block.canonicalName, + *name, + "Part names are unique under case-insensitive lookup."); + return; + } + parts_.push_back({*name, block.location, {}, {}, {}, {}, {}}); + currentPart_ = parts_.size() - 1U; + partElementsSeen_ = false; + partSetsSeen_ = false; + partSectionsSeen_ = false; + beamSectionContextActive_ = false; + } + + void parsePartBlock(const KeywordBlock& block) { + if (block.canonicalName == "END PART") { + if (!validateParameters(block, {}) || !requireNoData(block)) { + return; + } + const RawPart& part = parts_[*currentPart_]; + if (part.nodes.empty() || part.elements.empty() || + part.sections.empty()) { + invalidKeywordLocation( + block, + "A part closes only after node, element, and beam-section blocks."); + return; + } + currentPart_.reset(); + pendingSection_.reset(); + beamSectionContextActive_ = false; + return; + } + if (block.canonicalName == "PART") { + inputFailure( + "invalid-keyword-location", + block.location, + block.canonicalName, + "", + "Nested part blocks are not supported."); + return; + } + if (block.canonicalName == "ASSEMBLY") { + inputFailure( + "unsupported-nested-assembly", + block.location, + block.canonicalName, + "", + "An assembly cannot be nested in a part."); + return; + } + + RawPart& part = parts_[*currentPart_]; + if (block.canonicalName == "NODE") { + beamSectionContextActive_ = false; + if (partElementsSeen_ || partSetsSeen_ || partSectionsSeen_) { + invalidKeywordLocation( + block, + "NODE blocks must precede element, set, and section blocks."); + return; + } + pendingSection_.reset(); + parseNodes(block, part); + } else if (block.canonicalName == "ELEMENT") { + beamSectionContextActive_ = false; + if (part.nodes.empty() || partSetsSeen_ || partSectionsSeen_) { + invalidKeywordLocation( + block, + "ELEMENT blocks follow at least one NODE block and precede sets and sections."); + return; + } + pendingSection_.reset(); + parseElements(block, part); + partElementsSeen_ = !failure_; + } else if (block.canonicalName == "NSET") { + beamSectionContextActive_ = false; + if (!partElementsSeen_ || partSectionsSeen_) { + invalidKeywordLocation( + block, + "Part sets follow element blocks and precede beam sections."); + return; + } + pendingSection_.reset(); + parseSet(block, true, part); + partSetsSeen_ = !failure_; + } else if (block.canonicalName == "ELSET") { + beamSectionContextActive_ = false; + if (!partElementsSeen_ || partSectionsSeen_) { + invalidKeywordLocation( + block, + "Part sets follow element blocks and precede beam sections."); + return; + } + pendingSection_.reset(); + parseSet(block, false, part); + partSetsSeen_ = !failure_; + } else if (block.canonicalName == "BEAM GENERAL SECTION") { + if (!partElementsSeen_) { + invalidKeywordLocation( + block, + "BEAM GENERAL SECTION follows the part mesh and optional sets."); + return; + } + parseBeamSection(block, part); + partSectionsSeen_ = !failure_; + beamSectionContextActive_ = !failure_; + } else if (block.canonicalName == "SECTION POINTS") { + parseSectionPoints(block, part); + } else if (block.canonicalName == "TRANSVERSE SHEAR STIFFNESS") { + pendingSection_.reset(); + if (!beamSectionContextActive_) { + inputFailure( + "invalid-keyword-location", + block.location, + block.canonicalName, + "", + "The ignored shear keyword still requires beam-section context."); + return; + } + addIgnoredWarning(block); + } else { + pendingSection_.reset(); + beamSectionContextActive_ = false; + rejectUnknown(block); + } + } + + void parseNodes(const KeywordBlock& block, RawPart& part) { + if (!validateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + inputFailure( + "invalid-data-arity", block.location, block.canonicalName, + "", "NODE requires at least one four-field data row."); + } + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 4U) { + inputFailure( + "invalid-data-arity", row.location, block.canonicalName, + "", "NODE rows require label, x, y, z."); + return; + } + RawNode node{}; + node.labelText = row.fields[0]; + node.location = row.location; + if (!parseInteger( + row.fields[0], node.label, row.location, + block.canonicalName, true)) { + return; + } + if (std::any_of( + part.nodes.begin(), part.nodes.end(), + [&node](const RawNode& existing) { + return existing.label == node.label; + })) { + inputFailure( + "duplicate-entity", row.location, block.canonicalName, + node.labelText, "Node labels are unique within a part."); + return; + } + for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { + if (!parseDouble( + row.fields[coordinate + 1U], + node.coordinates[coordinate], + row.location, + block.canonicalName)) { + return; + } + } + part.nodes.push_back(std::move(node)); + } + } + + void parseElements(const KeywordBlock& block, RawPart& part) { + if (!validateParameters(block, {"TYPE"})) { + return; + } + const auto* type = requiredParameterValue(block, "TYPE"); + if (type == nullptr) { + return; + } + if (!equalName(*type, "B33")) { + inputFailure( + "unsupported-element-formulation", + block.location, + block.canonicalName, + *type, + "Only TYPE=B33 maps to the V0 Euler beam."); + return; + } + if (block.data.empty()) { + inputFailure( + "invalid-data-arity", block.location, block.canonicalName, + "", "ELEMENT requires at least one three-field row."); + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 3U) { + inputFailure( + "invalid-data-arity", row.location, block.canonicalName, + "", "B33 rows require label, node 1, node 2."); + return; + } + RawElement element{}; + element.labelText = row.fields[0]; + element.location = row.location; + if (!parseInteger( + row.fields[0], element.label, row.location, + block.canonicalName, true) || + !parseInteger( + row.fields[1], element.nodeLabels[0], row.location, + block.canonicalName, true) || + !parseInteger( + row.fields[2], element.nodeLabels[1], row.location, + block.canonicalName, true)) { + return; + } + if (std::any_of( + part.elements.begin(), part.elements.end(), + [&element](const RawElement& existing) { + return existing.label == element.label; + })) { + inputFailure( + "duplicate-entity", row.location, block.canonicalName, + element.labelText, + "Element labels are unique within a part."); + return; + } + part.elements.push_back(std::move(element)); + } + } + + bool parseSetMembers( + const KeywordBlock& block, + bool generate, + std::vector& members) { + if (block.data.empty()) { + return inputFailure( + "invalid-data-arity", block.location, block.canonicalName, + "", "A set requires at least one member row."); + } + for (const auto& data : block.data) { + auto fields = withoutTrailingEmpty(data.fields); + if (generate) { + if (fields.size() != 3U) { + return inputFailure( + "invalid-data-arity", data.location, + block.canonicalName, "", + "GENERATE rows require first, last, increment."); + } + std::int64_t first = 0; + std::int64_t last = 0; + std::int64_t increment = 0; + if (!parseInteger( + fields[0], first, data.location, + block.canonicalName, true) || + !parseInteger( + fields[1], last, data.location, + block.canonicalName, true)) { + return false; + } + if (!parseInteger( + fields[2], increment, data.location, + block.canonicalName, false) || + increment <= 0 || first > last || + (last - first) % increment != 0) { + return inputFailure( + "invalid-set-range", data.location, + block.canonicalName, "", + "GENERATE requires an inclusive range reached by a positive increment."); + } + for (std::int64_t label = first; label <= last;) { + members.push_back(label); + if (label > last - increment) { + break; + } + label += increment; + } + } else { + if (fields.empty() || + std::any_of( + fields.begin(), fields.end(), + [](const std::string& field) { return field.empty(); })) { + return inputFailure( + "invalid-data-arity", data.location, + block.canonicalName, "", + "Explicit set rows require non-empty member labels."); + } + for (const auto& field : fields) { + std::int64_t label = 0; + if (!parseInteger( + field, label, data.location, + block.canonicalName, true)) { + return false; + } + members.push_back(label); + } + } + } + std::set unique; + for (const auto member : members) { + if (!unique.insert(member).second) { + return inputFailure( + "duplicate-entity", block.location, + block.canonicalName, std::to_string(member), + "A set cannot repeat the same source member."); + } + } + return true; + } + + void parseSet(const KeywordBlock& block, bool nodeSet, RawPart& part) { + const std::string_view nameParameter = nodeSet ? "NSET" : "ELSET"; + if (!validateParameters(block, {nameParameter, "GENERATE"})) { + return; + } + const auto* name = requiredParameterValue(block, nameParameter); + if (name == nullptr) { + return; + } + const auto* generateParameter = parameter(block, "GENERATE"); + if (generateParameter != nullptr && generateParameter->value) { + inputFailure( + "invalid-keyword-parameter", block.location, + block.canonicalName, "GENERATE", + "GENERATE is a valueless flag."); + return; + } + if (containsSetName(part, *name)) { + inputFailure( + "duplicate-entity", block.location, block.canonicalName, + *name, "Set names are unique within a part."); + return; + } + RawSet set{*name, {}, block.location}; + if (!parseSetMembers(block, generateParameter != nullptr, set.members)) { + return; + } + if (nodeSet) { + part.nodeSets.push_back(std::move(set)); + } else { + part.elementSets.push_back(std::move(set)); + } + } + + void parseBeamSection(const KeywordBlock& block, RawPart& part) { + pendingSection_.reset(); + if (!validateParameters(block, {"ELSET", "MATERIAL", "SECTION"})) { + return; + } + const auto* elementSet = requiredParameterValue(block, "ELSET"); + const auto* material = requiredParameterValue(block, "MATERIAL"); + const auto* section = requiredParameterValue(block, "SECTION"); + if (elementSet == nullptr || material == nullptr || section == nullptr) { + return; + } + if (!equalName(*section, "GENERAL")) { + inputFailure( + "unsupported-section-formulation", block.location, + block.canonicalName, *section, + "Only SECTION=GENERAL belongs to the approved subset."); + return; + } + if (std::any_of( + part.sections.begin(), part.sections.end(), + [elementSet](const RawSection& existing) { + return equalName(existing.elementSetName, *elementSet); + })) { + inputFailure( + "duplicate-entity", block.location, block.canonicalName, + *elementSet, + "An element set can receive only one beam section."); + return; + } + if (block.data.size() != 2U || block.data[0].fields.size() != 5U || + block.data[1].fields.size() != 3U) { + inputFailure( + "invalid-data-arity", block.location, block.canonicalName, + *elementSet, + "A general section requires one five-field property row and one three-field axis row."); + return; + } + RawSection raw{*elementSet, *material, {}, {}, {}, block.location}; + for (std::size_t property = 0U; property < 5U; ++property) { + if (!parseModelDouble( + block.data[0].fields[property], raw.properties[property], + block.data[0].location, block.canonicalName, + "invalid-beam-property", + "Beam section properties must be finite.")) { + return; + } + } + for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { + if (!parseModelDouble( + block.data[1].fields[coordinate], raw.firstAxis[coordinate], + block.data[1].location, block.canonicalName, + "invalid-beam-guide-vector", + "The beam guide vector must be finite.")) { + return; + } + } + part.sections.push_back(std::move(raw)); + pendingSection_ = part.sections.size() - 1U; + } + + void parseSectionPoints(const KeywordBlock& block, RawPart& part) { + if (!pendingSection_ || !validateParameters(block, {}) || + block.data.empty()) { + if (!failure_) { + inputFailure( + "invalid-keyword-location", block.location, + block.canonicalName, "", + "SECTION POINTS must immediately follow a general section."); + } + return; + } + RawSection& section = part.sections[*pendingSection_]; + for (const auto& row : block.data) { + if (row.fields.size() != 2U) { + inputFailure( + "invalid-data-arity", row.location, + block.canonicalName, section.elementSetName, + "Section-point rows require x1 and x2."); + return; + } + std::array point{}; + if (!parseDouble( + row.fields[0], point[0], row.location, + block.canonicalName) || + !parseDouble( + row.fields[1], point[1], row.location, + block.canonicalName)) { + return; + } + if (std::find( + section.sectionPoints.begin(), + section.sectionPoints.end(), point) != + section.sectionPoints.end()) { + inputFailure( + "duplicate-entity", row.location, + block.canonicalName, section.elementSetName, + "Section points must be unique within a section."); + return; + } + section.sectionPoints.push_back(point); + } + pendingSection_.reset(); + } + + void parseAssemblyStart(const KeywordBlock& block) { + if (assemblySeen_) { + inputFailure( + "unsupported-nested-assembly", block.location, + block.canonicalName, "", + "V0 accepts exactly one non-nested assembly."); + return; + } + if (parts_.empty() || !materials_.empty() || modelBoundarySeen_) { + invalidKeywordLocation( + block, + "The sole assembly follows all part blocks and precedes model data."); + return; + } + if (!validateParameters(block, {"NAME"}) || !requireNoData(block)) { + return; + } + if (requiredParameterValue(block, "NAME") == nullptr) { + return; + } + assemblySeen_ = true; + inAssembly_ = true; + assemblySetSeen_ = false; + } + + void parseAssemblyBlock(const KeywordBlock& block) { + if (block.canonicalName == "END ASSEMBLY") { + if (!validateParameters(block, {}) || !requireNoData(block)) { + return; + } + if (instances_.empty()) { + invalidKeywordLocation( + block, + "The assembly requires at least one identity instance."); + return; + } + inAssembly_ = false; + return; + } + if (block.canonicalName == "ASSEMBLY") { + inputFailure( + "unsupported-nested-assembly", block.location, + block.canonicalName, "", + "Nested or duplicate assembly blocks are unsupported."); + } else if (block.canonicalName == "INSTANCE") { + if (assemblySetSeen_) { + invalidKeywordLocation( + block, + "All identity instances must precede assembly-level sets."); + return; + } + parseInstanceStart(block); + } else if (block.canonicalName == "NSET") { + if (instances_.empty()) { + invalidKeywordLocation( + block, + "Assembly sets follow at least one identity instance."); + return; + } + assemblySetSeen_ = true; + parseAssemblySet(block, true); + } else if (block.canonicalName == "ELSET") { + if (instances_.empty()) { + invalidKeywordLocation( + block, + "Assembly sets follow at least one identity instance."); + return; + } + assemblySetSeen_ = true; + parseAssemblySet(block, false); + } else { + rejectUnknown(block); + } + } + + void parseInstanceStart(const KeywordBlock& block) { + if (parameter(block, "DEPENDENT") != nullptr || + parameter(block, "INDEPENDENT") != nullptr) { + inputFailure( + "unsupported-instance-mesh-semantics", block.location, + block.canonicalName, "", + "Dependent and independent instance mesh semantics are unsupported."); + return; + } + if (!validateParameters(block, {"NAME", "PART"})) { + return; + } + const auto* name = requiredParameterValue(block, "NAME"); + const auto* part = requiredParameterValue(block, "PART"); + if (name == nullptr || part == nullptr) { + return; + } + if (!block.data.empty()) { + inputFailure( + "unsupported-instance-transform", block.data.front().location, + block.canonicalName, *name, + "Instance translation or rotation data is unsupported."); + return; + } + if (containsName(instances_, *name)) { + inputFailure( + "duplicate-entity", block.location, block.canonicalName, + *name, "Instance names are globally unique."); + return; + } + instances_.push_back({*name, *part, block.location}); + inInstance_ = true; + } + + void parseInstanceBlock(const KeywordBlock& block) { + if (block.canonicalName == "END INSTANCE") { + if (!validateParameters(block, {}) || !requireNoData(block)) { + return; + } + inInstance_ = false; + return; + } + if (block.canonicalName == "ASSEMBLY") { + inputFailure( + "unsupported-nested-assembly", block.location, + block.canonicalName, "", + "An assembly cannot be nested in an instance."); + return; + } + inputFailure( + "unsupported-instance-mesh-semantics", block.location, + block.canonicalName, instances_.back().name, + "Instance-local mesh definitions are unsupported."); + } + + void parseAssemblySet(const KeywordBlock& block, bool nodeSet) { + const std::string_view nameParameter = nodeSet ? "NSET" : "ELSET"; + if (!validateParameters( + block, {nameParameter, "INSTANCE", "GENERATE"})) { + return; + } + const auto* name = requiredParameterValue(block, nameParameter); + const auto* instance = requiredParameterValue(block, "INSTANCE"); + if (name == nullptr || instance == nullptr) { + return; + } + const auto* generateParameter = parameter(block, "GENERATE"); + if (generateParameter != nullptr && generateParameter->value) { + inputFailure( + "invalid-keyword-parameter", block.location, + block.canonicalName, "GENERATE", + "GENERATE is a valueless flag."); + return; + } + if (containsAssemblySetName(*name)) { + inputFailure( + "duplicate-entity", block.location, block.canonicalName, + *name, "Assembly set names are unique."); + return; + } + RawAssemblySet set{nodeSet, *name, *instance, {}, block.location}; + if (!parseSetMembers(block, generateParameter != nullptr, set.members)) { + return; + } + assemblySets_.push_back(std::move(set)); + } + + void parseMaterial(const KeywordBlock& block) { + if (!assemblySeen_ || modelBoundarySeen_) { + invalidKeywordLocation( + block, + "Material definitions follow the assembly and precede model boundaries."); + return; + } + if (!validateParameters(block, {"NAME"}) || !requireNoData(block)) { + return; + } + const auto* name = requiredParameterValue(block, "NAME"); + if (name == nullptr) { + return; + } + if (containsName(materials_, *name)) { + inputFailure( + "duplicate-entity", block.location, block.canonicalName, + *name, "Material names are globally unique."); + return; + } + materials_.push_back({*name, 0.0, 0.0, false, block.location, {}}); + materialEligible_ = materials_.size() - 1U; + } + + void parseElastic(const KeywordBlock& block) { + if (!materialEligible_) { + inputFailure( + "invalid-keyword-location", block.location, + block.canonicalName, "", + "ELASTIC must immediately follow MATERIAL."); + return; + } + if (!validateParameters(block, {}) || block.data.size() != 1U || + block.data[0].fields.size() != 2U) { + if (!failure_) { + inputFailure( + "invalid-data-arity", block.location, + block.canonicalName, materials_[*materialEligible_].name, + "ELASTIC requires exactly one E, nu row."); + } + return; + } + RawMaterial& material = materials_[*materialEligible_]; + if (material.hasElastic) { + inputFailure( + "duplicate-entity", block.location, block.canonicalName, + material.name, "A material accepts one ELASTIC definition."); + return; + } + if (!parseModelDouble( + block.data[0].fields[0], material.youngsModulus, + block.data[0].location, block.canonicalName, + "invalid-beam-property", + "Elastic material values must be finite.") || + !parseModelDouble( + block.data[0].fields[1], material.poissonRatio, + block.data[0].location, block.canonicalName, + "invalid-beam-property", + "Elastic material values must be finite.")) { + return; + } + material.hasElastic = true; + material.elasticLocation = block.data[0].location; + } + + void parseBoundary( + const KeywordBlock& block, + std::vector& destination) { + if (!validateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + inputFailure( + "invalid-data-arity", block.location, + block.canonicalName, "", + "BOUNDARY requires one or more data rows."); + } + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 3U && row.fields.size() != 4U) { + inputFailure( + "invalid-data-arity", row.location, + block.canonicalName, "", + "BOUNDARY rows require target, first DOF, last DOF, and optional value."); + return; + } + if (row.fields[0].empty()) { + inputFailure( + "unresolved-reference", row.location, + block.canonicalName, "", + "A boundary target cannot be empty."); + return; + } + std::int64_t first = 0; + std::int64_t last = 0; + if (!parseInteger( + row.fields[1], first, row.location, + block.canonicalName, true) || + !parseInteger( + row.fields[2], last, row.location, + block.canonicalName, true)) { + return; + } + if (first < 1 || first > 6 || last < first || last > 6) { + inputFailure( + "invalid-dof", row.location, block.canonicalName, + row.fields[0], "Boundary DOFs must be an ordered range in 1..6."); + return; + } + double value = 0.0; + if (row.fields.size() == 4U && + !parseDouble( + row.fields[3], value, row.location, + block.canonicalName)) { + return; + } + destination.push_back({ + row.fields[0], + static_cast(first), + static_cast(last), + value, + row.location}); + } + } + + void parseCload(const KeywordBlock& block, std::vector& loads) { + if (!validateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + inputFailure( + "invalid-data-arity", block.location, + block.canonicalName, "", "CLOAD requires one or more rows."); + } + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 3U || row.fields[0].empty()) { + inputFailure( + "invalid-data-arity", row.location, + block.canonicalName, "", + "CLOAD rows require target, DOF, magnitude."); + return; + } + std::int64_t dof = 0; + double magnitude = 0.0; + if (!parseInteger( + row.fields[1], dof, row.location, + block.canonicalName, true) || + !parseDouble( + row.fields[2], magnitude, row.location, + block.canonicalName)) { + return; + } + if (dof < 1 || dof > 6) { + inputFailure( + "invalid-dof", row.location, block.canonicalName, + row.fields[0], "CLOAD DOF must be in 1..6."); + return; + } + loads.push_back({ + row.fields[0], static_cast(dof), magnitude, row.location}); + } + } + + void parseStepStart(const KeywordBlock& block) { + if (stepSeen_) { + inputFailure( + "unsupported-multiple-step", block.location, + block.canonicalName, "", + "V0 accepts exactly one analysis step."); + return; + } + if (!assemblySeen_ || materials_.empty()) { + invalidKeywordLocation( + block, + "The sole step follows the complete assembly and material definitions."); + return; + } + if (!validateParameters(block, {"NAME", "NLGEOM"}) || + !requireNoData(block)) { + return; + } + const auto* nlgeom = parameter(block, "NLGEOM"); + if (nlgeom != nullptr) { + if (!nlgeom->value || nlgeom->value->empty()) { + inputFailure( + "invalid-keyword-parameter", block.location, + block.canonicalName, "NLGEOM", + "NLGEOM requires NO in the approved subset."); + return; + } + if (!equalName(*nlgeom->value, "NO")) { + modelFailure( + "unsupported-nonlinear-geometry", block.location, + block.canonicalName, *nlgeom->value, + "Only absent NLGEOM or NLGEOM=NO is supported."); + return; + } + } + stepSeen_ = true; + inStep_ = true; + step_ = RawStep{block.location, false, {}, {}, {}}; + stepLoadSeen_ = false; + stepNoOpSeen_ = false; + } + + void parseStepBlock(const KeywordBlock& block) { + if (block.canonicalName == "END STEP") { + activeOutput_ = false; + if (!validateParameters(block, {}) || !requireNoData(block)) { + return; + } + if (!step_.hasStatic) { + invalidKeywordLocation( + block, + "The sole step requires exactly one leading STATIC procedure."); + return; + } + inStep_ = false; + return; + } + if (block.canonicalName == "STEP") { + inputFailure( + "unsupported-multiple-step", block.location, + block.canonicalName, "", + "A second or nested step is unsupported."); + return; + } + if (block.canonicalName == "STATIC") { + activeOutput_ = false; + if (!step_.boundaries.empty() || !step_.loads.empty() || + stepNoOpSeen_) { + invalidKeywordLocation( + block, + "STATIC must be the first keyword in the sole step."); + return; + } + parseStatic(block); + } else if (block.canonicalName == "BOUNDARY") { + activeOutput_ = false; + if (!step_.hasStatic || stepLoadSeen_ || stepNoOpSeen_) { + invalidKeywordLocation( + block, + "Step boundaries follow STATIC and precede loads and no-op requests."); + return; + } + parseBoundary(block, step_.boundaries); + } else if (block.canonicalName == "CLOAD") { + activeOutput_ = false; + if (!step_.hasStatic || stepNoOpSeen_) { + invalidKeywordLocation( + block, + "CLOAD follows STATIC and boundaries and precedes no-op requests."); + return; + } + parseCload(block, step_.loads); + stepLoadSeen_ = !failure_; + } else if (block.canonicalName == "RESTART") { + activeOutput_ = false; + if (!step_.hasStatic) { + invalidKeywordLocation( + block, + "Step no-op requests follow STATIC, boundaries, and loads."); + return; + } + stepNoOpSeen_ = true; + if (requireNoData(block)) { + addIgnoredWarning(block); + } + } else if (block.canonicalName == "OUTPUT") { + if (!step_.hasStatic) { + invalidKeywordLocation( + block, + "Step no-op requests follow STATIC, boundaries, and loads."); + return; + } + stepNoOpSeen_ = true; + parseOutputRoot(block); + } else if (block.canonicalName == "NODE OUTPUT" || + block.canonicalName == "ELEMENT OUTPUT" || + block.canonicalName == "CONTACT OUTPUT") { + parseOutputChild(block); + } else { + activeOutput_ = false; + rejectUnknown(block); + } + } + + void parseStatic(const KeywordBlock& block) { + if (step_.hasStatic || !validateParameters(block, {}) || + block.data.size() != 1U || block.data[0].fields.size() != 4U) { + if (!failure_) { + inputFailure( + "invalid-static-data", block.location, + block.canonicalName, "", + "STATIC requires exactly one row of four values."); + } + return; + } + for (std::size_t field = 0U; field < 4U; ++field) { + if (!parseDouble( + block.data[0].fields[field], step_.staticValues[field], + block.data[0].location, block.canonicalName)) { + return; + } + if (step_.staticValues[field] <= 0.0) { + inputFailure( + "invalid-static-data", block.data[0].location, + block.canonicalName, "", + "All four STATIC fields must be positive."); + return; + } + } + if (step_.staticValues[2] > step_.staticValues[3]) { + inputFailure( + "invalid-static-data", block.data[0].location, + block.canonicalName, "", + "STATIC minimum increment cannot exceed maximum increment."); + return; + } + step_.hasStatic = true; + } + + void parseOutputRoot(const KeywordBlock& block) { + const auto* field = parameter(block, "FIELD"); + const auto* history = parameter(block, "HISTORY"); + if ((field == nullptr) == (history == nullptr) || + (field != nullptr && field->value) || + (history != nullptr && history->value)) { + inputFailure( + "unsupported-keyword", block.location, + block.canonicalName, "", + "OUTPUT must select exactly FIELD or HISTORY."); + return; + } + activeOutput_ = true; + addIgnoredWarning(block); + } + + void parseOutputChild(const KeywordBlock& block) { + if (!activeOutput_) { + inputFailure( + "invalid-keyword-location", block.location, + block.canonicalName, "", + "Output variable keywords require an active OUTPUT request."); + return; + } + addIgnoredWarning(block); + } + + void addIgnoredWarning(const KeywordBlock& block) { + // One warning per allowlisted keyword keeps no-op provenance stable; + // subordinate variable rows remain attached to that keyword record. + definition_.warnings.push_back({ + Severity::warning, + "ignored-input-keyword", + block.location, + block.canonicalName, + "", + "The allowlisted Abaqus keyword is ignored without semantic effect."}); + } + + void rejectUnknown(const KeywordBlock& block) { + inputFailure( + "unsupported-keyword", block.location, + block.canonicalName, "", + "The keyword is outside the approved Abaqus subset."); + } + + const RawPart* findPart(const std::string& name) const { + const auto found = std::find_if( + parts_.begin(), parts_.end(), [&name](const RawPart& part) { + return equalName(part.name, name); + }); + return found == parts_.end() ? nullptr : &*found; + } + + const RawInstance* findInstance(const std::string& name) const { + const auto found = std::find_if( + instances_.begin(), instances_.end(), + [&name](const RawInstance& instance) { + return equalName(instance.name, name); + }); + return found == instances_.end() ? nullptr : &*found; + } + + std::optional findMaterialIndex(const std::string& name) const { + for (std::size_t index = 0U; index < materials_.size(); ++index) { + if (equalName(materials_[index].name, name)) { + return static_cast(index); + } + } + return std::nullopt; + } + + const RawSet* findSet( + const std::vector& sets, + const std::string& name) const { + const auto found = std::find_if( + sets.begin(), sets.end(), [&name](const RawSet& set) { + return equalName(set.name, name); + }); + return found == sets.end() ? nullptr : &*found; + } + + const RawNode* findNode(const RawPart& part, std::int64_t label) const { + const auto found = std::find_if( + part.nodes.begin(), part.nodes.end(), + [label](const RawNode& node) { return node.label == label; }); + return found == part.nodes.end() ? nullptr : &*found; + } + + const RawElement* findElement( + const RawPart& part, + std::int64_t label) const { + const auto found = std::find_if( + part.elements.begin(), part.elements.end(), + [label](const RawElement& element) { return element.label == label; }); + return found == part.elements.end() ? nullptr : &*found; + } + + void finalizeModel() { + if (parts_.empty() || !assemblySeen_ || instances_.empty() || + materials_.empty() || !stepSeen_ || !step_.hasStatic) { + const auto location = input_.blocks.empty() + ? SourceLocation{input_.sourcePath, 0U} + : input_.blocks.back().location; + inputFailure( + "invalid-model-cardinality", location, "", "", + "The model requires part, assembly, identity instance, material, and one STATIC step."); + return; + } + + finalizeMaterials(); + if (failure_) { + return; + } + finalizePartsAndSections(); + if (failure_) { + return; + } + expandInstances(); + if (failure_) { + return; + } + expandAssemblySets(); + if (failure_) { + return; + } + finalizeStep(); + } + + void finalizeMaterials() { + for (const auto& material : materials_) { + if (!material.hasElastic) { + inputFailure( + "unresolved-reference", material.location, + "MATERIAL", material.name, + "A material must own exactly one ELASTIC row."); + return; + } + const double denominator = 2.0 * (1.0 + material.poissonRatio); + const double shearModulus = material.youngsModulus / denominator; + // The approved ledger deliberately validates derived G, not an + // independent upper bound on Poisson's ratio. + if (!(material.youngsModulus > 0.0) || + !std::isfinite(shearModulus) || !(shearModulus > 0.0)) { + modelFailure( + "invalid-beam-property", material.elasticLocation, + "ELASTIC", material.name, + "E and the derived G=E/(2*(1+nu)) must be positive."); + return; + } + definition_.materials.push_back({ + material.name, + material.youngsModulus, + material.poissonRatio, + material.location}); + } + } + + void finalizePartsAndSections() { + partSectionAssignments_.resize(parts_.size()); + for (std::size_t partIndex = 0U; partIndex < parts_.size(); ++partIndex) { + const RawPart& part = parts_[partIndex]; + if (part.nodes.empty() || part.elements.empty() || + part.sections.empty()) { + inputFailure( + "invalid-model-cardinality", part.location, + "PART", part.name, + "A part requires nodes, B33 elements, and a beam section."); + return; + } + + PartDefinition partDefinition{}; + partDefinition.name = part.name; + partDefinition.location = part.location; + for (const auto& node : part.nodes) { + partDefinition.nodeSourceLabels.push_back(node.label); + } + for (const auto& element : part.elements) { + partDefinition.elementSourceLabels.push_back(element.label); + if (findNode(part, element.nodeLabels[0]) == nullptr || + findNode(part, element.nodeLabels[1]) == nullptr) { + inputFailure( + "unresolved-reference", element.location, + "ELEMENT", element.labelText, + "Element connectivity must resolve within its part."); + return; + } + } + for (const auto& set : part.nodeSets) { + partDefinition.nodeSetNames.push_back(set.name); + for (const auto label : set.members) { + if (findNode(part, label) == nullptr) { + inputFailure( + "unresolved-reference", set.location, + "NSET", set.name, + "Every node-set member must resolve within its part."); + return; + } + } + } + for (const auto& set : part.elementSets) { + partDefinition.elementSetNames.push_back(set.name); + for (const auto label : set.members) { + if (findElement(part, label) == nullptr) { + inputFailure( + "unresolved-reference", set.location, + "ELSET", set.name, + "Every element-set member must resolve within its part."); + return; + } + } + } + definition_.parts.push_back(std::move(partDefinition)); + + auto& assignments = partSectionAssignments_[partIndex]; + for (const auto& section : part.sections) { + const auto* elementSet = findSet( + part.elementSets, section.elementSetName); + const auto materialIndex = findMaterialIndex(section.materialName); + if (elementSet == nullptr || !materialIndex) { + inputFailure( + "unresolved-reference", section.location, + "BEAM GENERAL SECTION", section.elementSetName, + "Section ELSET and MATERIAL references must resolve."); + return; + } + if (section.properties[2] != 0.0) { + modelFailure( + "unsupported-coupled-section", section.location, + "BEAM GENERAL SECTION", section.elementSetName, + "V0 requires exact I12=0."); + return; + } + if (!(section.properties[0] > 0.0) || + !(section.properties[1] > 0.0) || + !(section.properties[3] > 0.0) || + !(section.properties[4] > 0.0)) { + modelFailure( + "invalid-beam-property", section.location, + "BEAM GENERAL SECTION", section.elementSetName, + "A, I11, I22, and J must be positive."); + return; + } + const EntityIndex sectionIndex = + static_cast(definition_.sections.size()); + definition_.sections.push_back({ + section.elementSetName, + section.properties[0], + section.properties[1], + section.properties[2], + section.properties[3], + section.properties[4], + section.firstAxis, + section.sectionPoints, + section.location}); + for (const auto elementLabel : elementSet->members) { + if (!assignments.emplace( + elementLabel, + std::make_pair(sectionIndex, *materialIndex)).second) { + inputFailure( + "duplicate-entity", section.location, + "BEAM GENERAL SECTION", + std::to_string(elementLabel), + "An element cannot receive multiple section assignments."); + return; + } + } + } + for (const auto& element : part.elements) { + if (assignments.find(element.label) == assignments.end()) { + inputFailure( + "unresolved-reference", element.location, + "ELEMENT", element.labelText, + "Every B33 element requires a resolved section assignment."); + return; + } + } + } + } + + std::size_t partIndex(const RawPart& part) const { + return static_cast(&part - parts_.data()); + } + + bool validateGeometry( + const RawElement& raw, + const Node& first, + const Node& second, + const GeneralBeamSection& section) { + const auto norm = [](const std::array& vector) { + return std::hypot(vector[0], vector[1], vector[2]); + }; + const auto maximumAbsolute = [](const std::array& vector) { + return std::max({ + std::abs(vector[0]), + std::abs(vector[1]), + std::abs(vector[2])}); + }; + + // Compare both approved inequalities after a common scaling. This + // preserves the exact ratios while avoiding overflow in x*x and in + // subtraction between large finite coordinates. + const double globalCoordinateScale = std::max({ + 1.0, + maximumAbsolute(first.coordinates), + maximumAbsolute(second.coordinates)}); + std::array firstScaled{}; + std::array secondScaled{}; + std::array deltaScaled{}; + for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { + firstScaled[coordinate] = + first.coordinates[coordinate] / globalCoordinateScale; + secondScaled[coordinate] = + second.coordinates[coordinate] / globalCoordinateScale; + deltaScaled[coordinate] = + secondScaled[coordinate] - firstScaled[coordinate]; + } + const double lengthRatio = norm(deltaScaled); + const double coordinateNormRatio = std::max({ + 1.0 / globalCoordinateScale, + norm(firstScaled), + norm(secondScaled)}); + if (!(lengthRatio > 1.0e-12 * coordinateNormRatio)) { + return modelFailure( + "invalid-beam-length", raw.location, "ELEMENT", + raw.labelText, + "Beam length fails the approved scale-aware threshold."); + } + std::array tangent{ + deltaScaled[0] / lengthRatio, + deltaScaled[1] / lengthRatio, + deltaScaled[2] / lengthRatio}; + + const double globalGuideScale = + std::max(1.0, maximumAbsolute(section.firstAxis)); + std::array guideScaled{}; + for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { + guideScaled[coordinate] = + section.firstAxis[coordinate] / globalGuideScale; + } + const double projection = + guideScaled[0] * tangent[0] + + guideScaled[1] * tangent[1] + + guideScaled[2] * tangent[2]; + std::array perpendicular{ + guideScaled[0] - projection * tangent[0], + guideScaled[1] - projection * tangent[1], + guideScaled[2] - projection * tangent[2]}; + const double guideNormRatio = std::max( + 1.0 / globalGuideScale, + norm(guideScaled)); + if (!(norm(perpendicular) > 1.0e-12 * guideNormRatio)) { + return modelFailure( + "invalid-beam-guide-vector", section.location, + "BEAM GENERAL SECTION", raw.labelText, + "The first section axis cannot be zero or tangent-parallel."); + } + return true; + } + + void expandInstances() { + for (const auto& rawInstance : instances_) { + const RawPart* part = findPart(rawInstance.partName); + if (part == nullptr) { + inputFailure( + "unresolved-reference", rawInstance.location, + "INSTANCE", rawInstance.name, + "INSTANCE PART must resolve case-insensitively."); + return; + } + InstanceDefinition instance{ + rawInstance.name, part->name, {}, {}, rawInstance.location}; + std::map nodeIndices; + std::map elementIndices; + + // Instance order, followed by part-local declaration order, is the + // sole source of stable expanded internal IDs. + for (const auto& rawNode : part->nodes) { + if (definition_.nodes.size() > + std::numeric_limits::max()) { + inputFailure( + "entity-index-overflow", rawNode.location, + "NODE", rawNode.labelText, + "Expanded node count exceeds EntityIndex capacity."); + return; + } + const EntityIndex index = + static_cast(definition_.nodes.size()); + definition_.nodes.push_back({ + {rawInstance.name, rawNode.label, rawNode.labelText}, + rawNode.coordinates, + rawNode.location}); + nodeIndices.emplace(rawNode.label, index); + instance.nodeMappings.push_back({rawNode.label, index}); + } + + const auto& assignments = + partSectionAssignments_[partIndex(*part)]; + for (const auto& rawElement : part->elements) { + const auto first = nodeIndices.find(rawElement.nodeLabels[0]); + const auto second = nodeIndices.find(rawElement.nodeLabels[1]); + const auto assignment = assignments.find(rawElement.label); + if (first == nodeIndices.end() || second == nodeIndices.end() || + assignment == assignments.end()) { + inputFailure( + "unresolved-reference", rawElement.location, + "ELEMENT", rawElement.labelText, + "Expanded connectivity and section assignment must resolve."); + return; + } + if (definition_.elements.size() > + std::numeric_limits::max()) { + inputFailure( + "entity-index-overflow", rawElement.location, + "ELEMENT", rawElement.labelText, + "Expanded element count exceeds EntityIndex capacity."); + return; + } + const EntityIndex index = + static_cast(definition_.elements.size()); + const auto& section = definition_.sections[assignment->second.first]; + if (!validateGeometry( + rawElement, + definition_.nodes[first->second], + definition_.nodes[second->second], + section)) { + return; + } + definition_.elements.push_back({ + {rawInstance.name, rawElement.label, rawElement.labelText}, + {first->second, second->second}, + assignment->second.second, + assignment->second.first, + rawElement.location}); + elementIndices.emplace(rawElement.label, index); + instance.elementMappings.push_back({rawElement.label, index}); + } + + for (const auto& rawSet : part->nodeSets) { + NodeSet set{rawSet.name, rawInstance.name, {}, rawSet.location}; + for (const auto member : rawSet.members) { + const auto found = nodeIndices.find(member); + if (found == nodeIndices.end()) { + inputFailure( + "unresolved-reference", rawSet.location, + "NSET", rawSet.name, + "Part node-set expansion failed."); + return; + } + set.nodeIndices.push_back(found->second); + } + definition_.nodeSets.push_back(std::move(set)); + } + for (const auto& rawSet : part->elementSets) { + ElementSet set{ + rawSet.name, rawInstance.name, {}, rawSet.location}; + for (const auto member : rawSet.members) { + const auto found = elementIndices.find(member); + if (found == elementIndices.end()) { + inputFailure( + "unresolved-reference", rawSet.location, + "ELSET", rawSet.name, + "Part element-set expansion failed."); + return; + } + set.elementIndices.push_back(found->second); + } + definition_.elementSets.push_back(std::move(set)); + } + definition_.instances.push_back(std::move(instance)); + } + } + + void expandAssemblySets() { + for (const auto& rawSet : assemblySets_) { + const RawInstance* rawInstance = findInstance(rawSet.instanceName); + if (rawInstance == nullptr) { + inputFailure( + "unresolved-reference", rawSet.location, + rawSet.isNodeSet ? "NSET" : "ELSET", rawSet.name, + "Assembly set INSTANCE must resolve."); + return; + } + const auto definitionInstance = std::find_if( + definition_.instances.begin(), definition_.instances.end(), + [&rawInstance](const InstanceDefinition& instance) { + return equalName(instance.name, rawInstance->name); + }); + if (definitionInstance == definition_.instances.end()) { + inputFailure( + "unresolved-reference", rawSet.location, + rawSet.isNodeSet ? "NSET" : "ELSET", rawSet.name, + "Assembly set instance expansion is unavailable."); + return; + } + if (rawSet.isNodeSet) { + NodeSet set{ + rawSet.name, definitionInstance->name, {}, rawSet.location}; + for (const auto member : rawSet.members) { + const auto mapping = std::find_if( + definitionInstance->nodeMappings.begin(), + definitionInstance->nodeMappings.end(), + [member](const SourceIndexMapping& value) { + return value.sourceLabel == member; + }); + if (mapping == definitionInstance->nodeMappings.end()) { + inputFailure( + "unresolved-reference", rawSet.location, + "NSET", rawSet.name, + "Assembly node-set member must resolve in its instance."); + return; + } + set.nodeIndices.push_back(mapping->internalIndex); + } + definition_.nodeSets.push_back(std::move(set)); + } else { + ElementSet set{ + rawSet.name, definitionInstance->name, {}, rawSet.location}; + for (const auto member : rawSet.members) { + const auto mapping = std::find_if( + definitionInstance->elementMappings.begin(), + definitionInstance->elementMappings.end(), + [member](const SourceIndexMapping& value) { + return value.sourceLabel == member; + }); + if (mapping == definitionInstance->elementMappings.end()) { + inputFailure( + "unresolved-reference", rawSet.location, + "ELSET", rawSet.name, + "Assembly element-set member must resolve in its instance."); + return; + } + set.elementIndices.push_back(mapping->internalIndex); + } + definition_.elementSets.push_back(std::move(set)); + } + } + } + + std::optional> resolveNodeTarget( + const std::string& target, + const SourceLocation& location, + const std::string& keyword) { + std::vector matchingSets; + for (const auto& set : definition_.nodeSets) { + if (equalName(set.name, target)) { + matchingSets.push_back(&set); + } + } + if (matchingSets.size() == 1U) { + return matchingSets.front()->nodeIndices; + } + if (matchingSets.size() > 1U) { + inputFailure( + "unresolved-reference", location, keyword, target, + "The node-set target is ambiguous across identity instances."); + return std::nullopt; + } + + std::int64_t label = 0; + if (tryPositiveInteger(target, label)) { + std::vector matchingNodes; + for (std::size_t index = 0U; + index < definition_.nodes.size(); + ++index) { + if (definition_.nodes[index].sourceId.sourceLabel == label) { + matchingNodes.push_back(static_cast(index)); + } + } + if (matchingNodes.size() == 1U) { + return matchingNodes; + } + } + inputFailure( + "unresolved-reference", location, keyword, target, + "The boundary or load target must resolve to one node or node set."); + return std::nullopt; + } + + void finalizeStep() { + std::vector boundaries = modelBoundaries_; + boundaries.insert( + boundaries.end(), step_.boundaries.begin(), step_.boundaries.end()); + + std::map, double> prescribedValues; + for (const auto& boundary : boundaries) { + auto target = resolveNodeTarget( + boundary.target, boundary.location, "BOUNDARY"); + if (!target) { + return; + } + for (const auto node : *target) { + for (int dof = boundary.firstDof; dof <= boundary.lastDof; ++dof) { + const auto key = std::make_pair(node, dof); + const auto existing = prescribedValues.find(key); + if (existing != prescribedValues.end() && + existing->second != boundary.value) { + inputFailure( + "conflicting-boundary-condition", + boundary.location, + "BOUNDARY", + boundary.target, + "Expanded boundary rows prescribe different values to one node/DOF."); + return; + } + prescribedValues[key] = boundary.value; + } + } + } + for (const auto& load : step_.loads) { + if (!resolveNodeTarget(load.target, load.location, "CLOAD")) { + return; + } + } + + // Static time-control values are provenance only: V0 creates exactly + // the canonical final frame 0 and performs no increment loop here. + definition_.steps.push_back({ + "Step-1", + std::move(boundaries), + step_.loads, + step_.staticValues[0], + step_.staticValues[1], + step_.staticValues[2], + step_.staticValues[3], + step_.location}); + } + + const ParsedInput& input_; + ModelDefinition definition_{}; + std::optional failure_; + + std::vector parts_; + std::vector instances_; + std::vector assemblySets_; + std::vector materials_; + std::vector modelBoundaries_; + RawStep step_{}; + std::vector>> partSectionAssignments_; + + std::optional currentPart_; + std::optional pendingSection_; + std::optional materialEligible_; + bool headingSeen_{false}; + bool partElementsSeen_{false}; + bool partSetsSeen_{false}; + bool partSectionsSeen_{false}; + bool beamSectionContextActive_{false}; + bool assemblySeen_{false}; + bool assemblySetSeen_{false}; + bool modelBoundarySeen_{false}; + bool inAssembly_{false}; + bool inInstance_{false}; + bool stepSeen_{false}; + bool inStep_{false}; + bool stepLoadSeen_{false}; + bool stepNoOpSeen_{false}; + bool activeOutput_{false}; +}; + +} // namespace + +Result AbaqusDomainMapper::map(const ParsedInput& input) const { + return MappingContext{input}.run(); +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1c9c394..0accc78 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable( unit/core/status_test.cpp unit/math/matrix_test.cpp unit/math/vector_test.cpp + unit/io/abaqus/domain_mapper_test.cpp unit/io/abaqus/input_reader_test.cpp unit/io/abaqus/input_syntax_test.cpp unit/model/domain_test.cpp diff --git a/tests/unit/io/abaqus/domain_mapper_test.cpp b/tests/unit/io/abaqus/domain_mapper_test.cpp new file mode 100644 index 0000000..7489da4 --- /dev/null +++ b/tests/unit/io/abaqus/domain_mapper_test.cpp @@ -0,0 +1,590 @@ +#include "fesa/io/abaqus/domain_mapper.hpp" +#include "fesa/io/abaqus/input_reader.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class TemporaryInputFile { +public: + TemporaryInputFile(const std::string& stem, const std::string& content) + : path_{std::filesystem::temp_directory_path() / + ("fesa-domain-mapper-" + stem + ".inp")} { + std::ofstream stream{path_, std::ios::binary | std::ios::trunc}; + stream.write(content.data(), static_cast(content.size())); + if (!stream) { + throw std::runtime_error{"Unable to create domain mapper fixture."}; + } + } + + ~TemporaryInputFile() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +fesa::Result mapText( + const std::string& stem, + const std::string& content) { + const TemporaryInputFile input{stem, content}; + auto parsed = fesa::AbaqusInputReader{}.read(input.path()); + if (!parsed.hasValue()) { + return fesa::Result::failure(parsed.status()); + } + return fesa::AbaqusDomainMapper{}.map(parsed.value()); +} + +std::string readExactBytes(const std::filesystem::path& path) { + std::ifstream stream{path, std::ios::binary}; + if (!stream) { + throw std::runtime_error{"Unable to read legacy mapper fixture."}; + } + return std::string{ + std::istreambuf_iterator{stream}, + std::istreambuf_iterator{}}; +} + +std::filesystem::path repositoryRoot() { + auto path = std::filesystem::path{__FILE__}.parent_path(); + for (int parent = 0; parent < 4; ++parent) { + path = path.parent_path(); + } + return path; +} + +const fesa::Diagnostic* findDiagnostic( + const fesa::Status& status, + const std::string& code) { + const auto found = std::find_if( + status.diagnostics().begin(), + status.diagnostics().end(), + [&code](const fesa::Diagnostic& diagnostic) { + return diagnostic.code == code; + }); + return found == status.diagnostics().end() ? nullptr : &*found; +} + +std::string replaceOnce( + std::string text, + const std::string& from, + const std::string& to) { + const auto position = text.find(from); + if (position == std::string::npos) { + throw std::logic_error{"Mapper test mutation source was not found."}; + } + text.replace(position, from.size(), to); + return text; +} + +std::string minimalDeck() { + return R"inp(*Part, name=BeamPart +*Node +1, 0., 0., 0. +2, 1., 0., 0. +*Element, type=B33 +1, 1, 2 +*Elset, elset=BeamSet +1 +*Beam General Section, elset=BeamSet, material=Steel, section=GENERAL +1., 1., 0., 1., 1. +0., 1., 0. +*End Part +*Assembly, name=Assembly +*Instance, name=Beam-1, part=BeamPart +*End Instance +*Nset, nset=Root, instance=Beam-1 +1 +*Nset, nset=Tip, instance=Beam-1 +2 +*End Assembly +*Material, name=Steel +*Elastic +100., 0.25 +*Boundary +Root, 1, 6 +*Step, name=Load, nlgeom=NO +*Static +0.1, 1., 0.01, 1. +*Cload +Tip, 2, -1. +*End Step +)inp"; +} + +std::string supportedInventoryDeck(bool includeNoOps) { + const std::string preprint = includeNoOps + ? "*Preprint, echo=NO, model=NO, history=NO, contact=NO\n" + : ""; + const std::string shear = includeNoOps + ? "*Transverse Shear Stiffness\n1., 2., 3.\n" + : ""; + const std::string outputs = includeNoOps + ? R"inp(*Restart, write, frequency=0 +*Output, field +*Node Output +U, RF +*Element Output, directions=YES +S, SF +*Contact Output, variable=PRESELECT +*Output, history, variable=PRESELECT +)inp" + : ""; + + return std::string{R"inp(*hEaDiNg +Every supported keyword +)inp"} + preprint + R"inp(*pArT, NaMe=BeamPart +*nOdE +0001, 0., 0., 0. +0002, 2., 0., 0. +*eLeMeNt, TyPe=b33 +0007, 0001, 0002 +*nSeT, NsEt=RootLocal +0001 +*Nset, nset=AllLocal, generate +1, 2, 1 +*Elset, elset=BeamExplicit +0007 +*eLsEt, ElSeT=BeamAll, GeNeRaTe +7, 7, 1 +*Beam General Section, ELSET=beamall, MATERIAL=steel, SECTION=general +2., 3., 0., 4., 5. +0., 1., 0. +*Section Points +-0.5, 0.25 +0.5, -0.25 +)inp" + shear + R"inp(*eNd PaRt +*aSsEmBlY, name=Assembly +*iNsTaNcE, NAME=Beam-1, PART=beampart +*eNd InStAnCe +*Nset, nset=RootAssembly, instance=beam-1 +0001 +*Elset, elset=BeamAssembly, instance=BEAM-1 +0007 +*eNd AsSeMbLy +*mAtErIaL, NaMe=Steel +*eLaStIc +210000., 0.75 +*bOuNdArY +rootassembly, 1, 1, 0.125 +*sTeP, name=User-Spelling, nLgEoM=no +*sTaTiC +0.25, 1.5, 0.01, 1.5 +*Boundary +RootAssembly, 2, 2 +*cLoAd +RootAssembly, 6, -12.5 +)inp" + outputs + "*eNd StEp\n"; +} + +} // namespace + +TEST(InpDomainMapping, MapsEverySupportedKeywordAndLegacyDeck) { + auto result = mapText("supported-inventory", supportedInventoryDeck(true)); + ASSERT_TRUE(result.hasValue()); + const fesa::Domain& domain = result.value(); + + ASSERT_EQ(domain.nodes().size(), 2U); + EXPECT_EQ(domain.nodes()[0].sourceId.instanceName, "Beam-1"); + EXPECT_EQ(domain.nodes()[0].sourceId.sourceLabel, 1); + EXPECT_EQ(domain.nodes()[0].sourceId.sourceLabelText, "0001"); + EXPECT_EQ(domain.nodes()[1].sourceId.sourceLabelText, "0002"); + EXPECT_DOUBLE_EQ(domain.nodes()[1].coordinates[0], 2.0); + + ASSERT_EQ(domain.elements().size(), 1U); + EXPECT_EQ(domain.elements()[0].sourceId.sourceLabelText, "0007"); + EXPECT_EQ(domain.elements()[0].nodeIndices[0], 0U); + EXPECT_EQ(domain.elements()[0].nodeIndices[1], 1U); + + ASSERT_EQ(domain.materials().size(), 1U); + EXPECT_EQ(domain.materials()[0].name, "Steel"); + EXPECT_DOUBLE_EQ(domain.materials()[0].youngsModulus, 210000.0); + EXPECT_DOUBLE_EQ(domain.materials()[0].poissonRatio, 0.75); + + ASSERT_EQ(domain.sections().size(), 1U); + const auto& section = domain.sections()[0]; + EXPECT_DOUBLE_EQ(section.area, 2.0); + EXPECT_DOUBLE_EQ(section.i11, 3.0); + EXPECT_DOUBLE_EQ(section.i12, 0.0); + EXPECT_DOUBLE_EQ(section.i22, 4.0); + EXPECT_DOUBLE_EQ(section.torsionalConstant, 5.0); + EXPECT_EQ(section.firstAxis, (std::array{0.0, 1.0, 0.0})); + EXPECT_EQ( + section.sectionPoints, + (std::vector>{{-0.5, 0.25}, {0.5, -0.25}})); + EXPECT_EQ(domain.elements()[0].materialIndex, 0U); + EXPECT_EQ(domain.elements()[0].sectionIndex, 0U); + + ASSERT_EQ(domain.steps().size(), 1U); + const auto& step = domain.steps()[0]; + EXPECT_EQ(step.name, "Step-1"); + EXPECT_DOUBLE_EQ(step.initialIncrement, 0.25); + EXPECT_DOUBLE_EQ(step.timePeriod, 1.5); + EXPECT_DOUBLE_EQ(step.minimumIncrement, 0.01); + EXPECT_DOUBLE_EQ(step.maximumIncrement, 1.5); + ASSERT_EQ(step.boundaries.size(), 2U); + EXPECT_EQ(step.boundaries[0].target, "rootassembly"); + EXPECT_EQ(step.boundaries[0].firstDof, 1); + EXPECT_DOUBLE_EQ(step.boundaries[0].value, 0.125); + EXPECT_EQ(step.boundaries[1].lastDof, 2); + EXPECT_DOUBLE_EQ(step.boundaries[1].value, 0.0); + ASSERT_EQ(step.loads.size(), 1U); + EXPECT_EQ(step.loads[0].target, "RootAssembly"); + EXPECT_EQ(step.loads[0].dof, 6); + EXPECT_DOUBLE_EQ(step.loads[0].magnitude, -12.5); + EXPECT_EQ(domain.warnings().size(), 8U); + + const auto legacyPath = repositoryRoot() / "reference" / + "cantilever beam" / "cantilever beam.inp"; + const auto bytesBefore = readExactBytes(legacyPath); + const auto timestampBefore = std::filesystem::last_write_time(legacyPath); + auto parsedLegacy = fesa::AbaqusInputReader{}.read(legacyPath); + ASSERT_TRUE(parsedLegacy.hasValue()); + auto legacy = fesa::AbaqusDomainMapper{}.map(parsedLegacy.value()); + ASSERT_TRUE(legacy.hasValue()); + EXPECT_EQ(legacy.value().nodes().size(), 11U); + EXPECT_EQ(legacy.value().elements().size(), 10U); + EXPECT_EQ(legacy.value().materials().size(), 1U); + EXPECT_EQ(legacy.value().sections().size(), 1U); + EXPECT_EQ(legacy.value().steps().size(), 1U); + EXPECT_EQ(legacy.value().warnings().size(), 7U); + EXPECT_EQ(readExactBytes(legacyPath), bytesBefore); + EXPECT_EQ(std::filesystem::last_write_time(legacyPath), timestampBefore); +} + +TEST(InpDomainMapping, ExpandsSetsAndMultipleIdentityInstancesDeterministically) { + const std::string deck = R"inp(*Part, name=P +*Node +10, 0., 0., 0. +20, 1., 0., 0. +*Element, type=B33 +30, 10, 20 +*Nset, nset=Ends, generate +10, 20, 10 +*Elset, elset=AllElements +30 +*Beam General Section, elset=AllElements, material=M, section=GENERAL +1., 2., 0., 3., 4. +0., 1., 0. +*End Part +*Assembly, name=A +*Instance, name=First, part=P +*End Instance +*Instance, name=Second, part=P +*End Instance +*Nset, nset=OnlySecond, instance=Second +20 +*Elset, elset=OnlySecondBeam, instance=Second +30 +*End Assembly +*Material, name=M +*Elastic +1000., 0.25 +*Step +*Static +1., 1., 1., 1. +*Boundary +OnlySecond, 1, 1 +*Cload +OnlySecond, 2, 5. +*End Step +)inp"; + + auto result = mapText("multiple-instances", deck); + ASSERT_TRUE(result.hasValue()); + const fesa::Domain& domain = result.value(); + + ASSERT_EQ(domain.nodes().size(), 4U); + EXPECT_EQ(domain.nodes()[0].sourceId.instanceName, "First"); + EXPECT_EQ(domain.nodes()[0].sourceId.sourceLabel, 10); + EXPECT_EQ(domain.nodes()[1].sourceId.instanceName, "First"); + EXPECT_EQ(domain.nodes()[1].sourceId.sourceLabel, 20); + EXPECT_EQ(domain.nodes()[2].sourceId.instanceName, "Second"); + EXPECT_EQ(domain.nodes()[2].sourceId.sourceLabel, 10); + EXPECT_EQ(domain.nodes()[3].sourceId.instanceName, "Second"); + EXPECT_EQ(domain.nodes()[3].sourceId.sourceLabel, 20); + + ASSERT_EQ(domain.elements().size(), 2U); + EXPECT_EQ(domain.elements()[0].sourceId.instanceName, "First"); + EXPECT_EQ(domain.elements()[0].nodeIndices, + (std::array{0U, 1U})); + EXPECT_EQ(domain.elements()[1].sourceId.instanceName, "Second"); + EXPECT_EQ(domain.elements()[1].nodeIndices, + (std::array{2U, 3U})); + + ASSERT_EQ(domain.nodeSets().size(), 3U); + EXPECT_EQ(domain.nodeSets()[0].name, "Ends"); + EXPECT_EQ(domain.nodeSets()[0].instanceName, std::optional{"First"}); + EXPECT_EQ(domain.nodeSets()[0].nodeIndices, + (std::vector{0U, 1U})); + EXPECT_EQ(domain.nodeSets()[1].instanceName, std::optional{"Second"}); + EXPECT_EQ(domain.nodeSets()[1].nodeIndices, + (std::vector{2U, 3U})); + EXPECT_EQ(domain.nodeSets()[2].name, "OnlySecond"); + EXPECT_EQ(domain.nodeSets()[2].nodeIndices, + (std::vector{3U})); + + ASSERT_EQ(domain.elementSets().size(), 3U); + EXPECT_EQ(domain.elementSets()[0].instanceName, + std::optional{"First"}); + EXPECT_EQ(domain.elementSets()[0].elementIndices, + (std::vector{0U})); + EXPECT_EQ(domain.elementSets()[1].instanceName, + std::optional{"Second"}); + EXPECT_EQ(domain.elementSets()[1].elementIndices, + (std::vector{1U})); + EXPECT_EQ(domain.elementSets()[2].name, "OnlySecondBeam"); + EXPECT_EQ(domain.elementSets()[2].elementIndices, + (std::vector{1U})); + + ASSERT_EQ(domain.steps().size(), 1U); + EXPECT_EQ(domain.steps()[0].boundaries[0].target, "OnlySecond"); + EXPECT_EQ(domain.steps()[0].loads[0].target, "OnlySecond"); + + auto direct = mapText( + "direct-node-labels", + replaceOnce( + replaceOnce(minimalDeck(), "Root, 1, 6", "1, 1, 6"), + "Tip, 2, -1.", + "2, 2, -1.")); + ASSERT_TRUE(direct.hasValue()); + EXPECT_EQ(direct.value().steps()[0].boundaries[0].target, "1"); + EXPECT_EQ(direct.value().steps()[0].loads[0].target, "2"); + + auto aboveThresholds = mapText( + "above-geometry-thresholds", + replaceOnce( + replaceOnce(minimalDeck(), "2, 1., 0., 0.", "2, 2e-12, 0., 0."), + "0., 1., 0.", + "1., 2e-12, 0.")); + ASSERT_TRUE(aboveThresholds.hasValue()); + + auto largeFinite = mapText( + "large-finite-geometry", + replaceOnce( + replaceOnce( + replaceOnce(minimalDeck(), "1, 0., 0., 0.", "1, 1e308, 0., 0."), + "2, 1., 0., 0.", + "2, 1e308, 1e297, 0."), + "0., 1., 0.", + "1e308, 0., 0.")); + ASSERT_TRUE(largeFinite.hasValue()); +} + +TEST(InpDomainMapping, NoOpAllowlistWarnsWithoutSemanticEffect) { + auto plain = mapText("without-no-ops", supportedInventoryDeck(false)); + auto withNoOps = mapText("with-no-ops", supportedInventoryDeck(true)); + ASSERT_TRUE(plain.hasValue()); + ASSERT_TRUE(withNoOps.hasValue()); + + EXPECT_TRUE(plain.value().warnings().empty()); + ASSERT_EQ(withNoOps.value().warnings().size(), 8U); + EXPECT_EQ(withNoOps.value().warnings()[0].code, "ignored-input-keyword"); + EXPECT_EQ(withNoOps.value().warnings()[0].keyword, "PREPRINT"); + EXPECT_EQ(withNoOps.value().warnings()[1].keyword, + "TRANSVERSE SHEAR STIFFNESS"); + EXPECT_EQ(withNoOps.value().warnings()[2].keyword, "RESTART"); + EXPECT_EQ(withNoOps.value().warnings()[3].keyword, "OUTPUT"); + EXPECT_EQ(withNoOps.value().warnings()[4].keyword, "NODE OUTPUT"); + EXPECT_EQ(withNoOps.value().warnings()[5].keyword, "ELEMENT OUTPUT"); + EXPECT_EQ(withNoOps.value().warnings()[6].keyword, "CONTACT OUTPUT"); + EXPECT_EQ(withNoOps.value().warnings()[7].keyword, "OUTPUT"); + for (const auto& warning : withNoOps.value().warnings()) { + EXPECT_EQ(warning.severity, fesa::Severity::warning); + } + + EXPECT_EQ(withNoOps.value().nodes().size(), plain.value().nodes().size()); + EXPECT_EQ(withNoOps.value().elements().size(), plain.value().elements().size()); + EXPECT_EQ(withNoOps.value().materials().size(), plain.value().materials().size()); + EXPECT_EQ(withNoOps.value().sections().size(), plain.value().sections().size()); + EXPECT_EQ(withNoOps.value().nodeSets().size(), plain.value().nodeSets().size()); + EXPECT_EQ(withNoOps.value().elementSets().size(), plain.value().elementSets().size()); + EXPECT_EQ(withNoOps.value().steps().size(), plain.value().steps().size()); + EXPECT_EQ(withNoOps.value().steps()[0].boundaries.size(), + plain.value().steps()[0].boundaries.size()); + EXPECT_EQ(withNoOps.value().steps()[0].loads.size(), + plain.value().steps()[0].loads.size()); +} + +TEST(InpDomainMapping, RejectsUnsupportedAndInvalidPortfolio) { + struct InvalidCase { + std::string name; + std::string deck; + std::string expectedCode; + fesa::FailureCategory category; + }; + + const std::string base = minimalDeck(); + const std::vector cases{ + {"b31", replaceOnce(base, "type=B33", "type=B31"), + "unsupported-element-formulation", fesa::FailureCategory::input}, + {"transform", replaceOnce(base, "*End Instance\n", "1., 0., 0.\n*End Instance\n"), + "unsupported-instance-transform", fesa::FailureCategory::input}, + {"nested-assembly", replaceOnce(base, "*End Assembly\n", "*Assembly, name=Nested\n*End Assembly\n*End Assembly\n"), + "unsupported-nested-assembly", fesa::FailureCategory::input}, + {"multiple-step", base + "*Step\n*Static\n1., 1., 1., 1.\n*End Step\n", + "unsupported-multiple-step", fesa::FailureCategory::input}, + {"late-part", replaceOnce(base, "*End Assembly\n*Material", "*End Assembly\n*Part, name=Late\n*End Part\n*Material"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"material-before-assembly", replaceOnce(base, "*Assembly, name=Assembly", "*Material, name=Early\n*Elastic\n50., 0.2\n*Assembly, name=Assembly"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"material-after-model-boundary", replaceOnce(base, + "*Material, name=Steel\n*Elastic\n100., 0.25\n*Boundary\nRoot, 1, 6", + "*Boundary\nRoot, 1, 6\n*Material, name=Steel\n*Elastic\n100., 0.25"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"keyword-after-step", base + "*Preprint, echo=NO\n", + "invalid-keyword-location", fesa::FailureCategory::input}, + {"assembly-instance-after-set", replaceOnce(base, + "*Instance, name=Beam-1, part=BeamPart\n*End Instance\n*Nset, nset=Root, instance=Beam-1\n1", + "*Nset, nset=Root, instance=Beam-1\n1\n*Instance, name=Beam-1, part=BeamPart\n*End Instance"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"element-before-node", replaceOnce(base, + "*Node\n1, 0., 0., 0.\n2, 1., 0., 0.\n*Element, type=B33\n1, 1, 2", + "*Element, type=B33\n1, 1, 2\n*Node\n1, 0., 0., 0.\n2, 1., 0., 0."), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"shear-before-section-context", replaceOnce(base, + "*Beam General Section", + "*Transverse Shear Stiffness\n1., 2., 3.\n*Beam General Section"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"incomplete-part", replaceOnce(base, + base.substr(base.find("*Part"), base.find("*End Part") + std::string{"*End Part\n"}.size() - base.find("*Part")), + "*Part, name=BeamPart\n*End Part\n"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"empty-assembly", replaceOnce(base, + base.substr(base.find("*Assembly"), base.find("*End Assembly") + std::string{"*End Assembly\n"}.size() - base.find("*Assembly")), + "*Assembly, name=Assembly\n*End Assembly\n"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"cload-before-static", replaceOnce(base, + "*Static\n0.1, 1., 0.01, 1.\n*Cload\nTip, 2, -1.", + "*Cload\nTip, 2, -1.\n*Static\n0.1, 1., 0.01, 1."), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"static-after-boundary", replaceOnce( + replaceOnce(base, "*Boundary\nRoot, 1, 6\n*Step", "*Step"), + "*Static\n0.1, 1., 0.01, 1.", + "*Boundary\nRoot, 1, 6\n*Static\n0.1, 1., 0.01, 1."), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"boundary-after-cload", replaceOnce( + replaceOnce(base, "*Boundary\nRoot, 1, 6\n*Step", "*Step"), + "Tip, 2, -1.\n*End Step", + "Tip, 2, -1.\n*Boundary\nRoot, 1, 6\n*End Step"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"cload-after-no-op", replaceOnce(base, "*Cload", "*Restart, write, frequency=0\n*Cload"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"step-without-static", replaceOnce(base, + "*Static\n0.1, 1., 0.01, 1.\n*Cload\nTip, 2, -1.\n*End Step", + "*End Step"), + "invalid-keyword-location", fesa::FailureCategory::input}, + {"dependent-instance", replaceOnce(base, "part=BeamPart", "part=BeamPart, dependent=YES"), + "unsupported-instance-mesh-semantics", fesa::FailureCategory::input}, + {"coupled-section", replaceOnce(base, "1., 1., 0., 1., 1.", "1., 1., 0.5, 1., 1."), + "unsupported-coupled-section", fesa::FailureCategory::model}, + {"zero-length", replaceOnce(base, "2, 1., 0., 0.", "2, 0., 0., 0."), + "invalid-beam-length", fesa::FailureCategory::model}, + {"parallel-guide", replaceOnce(base, "0., 1., 0.", "1., 0., 0."), + "invalid-beam-guide-vector", fesa::FailureCategory::model}, + {"nonpositive-area", replaceOnce(base, "1., 1., 0., 1., 1.", "0., 1., 0., 1., 1."), + "invalid-beam-property", fesa::FailureCategory::model}, + {"nonpositive-derived-shear", replaceOnce(base, "100., 0.25", "100., -1.25"), + "invalid-beam-property", fesa::FailureCategory::model}, + {"nonfinite-elastic", replaceOnce(base, "100., 0.25", "inf, 0.25"), + "invalid-beam-property", fesa::FailureCategory::model}, + {"nonfinite-section-property", replaceOnce(base, "1., 1., 0., 1., 1.", "nan, 1., 0., 1., 1."), + "invalid-beam-property", fesa::FailureCategory::model}, + {"nonfinite-guide", replaceOnce(base, "0., 1., 0.", "0., inf, 0."), + "invalid-beam-guide-vector", fesa::FailureCategory::model}, + {"length-at-threshold", replaceOnce(base, "2, 1., 0., 0.", "2, 1e-12, 0., 0."), + "invalid-beam-length", fesa::FailureCategory::model}, + {"guide-at-threshold", replaceOnce(base, "0., 1., 0.", "1., 1e-12, 0."), + "invalid-beam-guide-vector", fesa::FailureCategory::model}, + {"duplicate-elastic", replaceOnce(base, "100., 0.25\n*Boundary", "100., 0.25\n*Elastic\n100., 0.25\n*Boundary"), + "duplicate-entity", fesa::FailureCategory::input}, + {"duplicate-node-label", replaceOnce(base, "2, 1., 0., 0.", "1, 1., 0., 0."), + "duplicate-entity", fesa::FailureCategory::input}, + {"dangling-connectivity", replaceOnce(base, "1, 1, 2", "1, 1, 9"), + "unresolved-reference", fesa::FailureCategory::input}, + {"invalid-dof", replaceOnce(base, "Root, 1, 6", "Root, 1, 7"), + "invalid-dof", fesa::FailureCategory::input}, + {"nonfinite-coordinate", replaceOnce(base, "1., 0., 0.", "nan, 0., 0."), + "invalid-numeric-value", fesa::FailureCategory::input}, + {"malformed-node-arity", replaceOnce(base, "1, 0., 0., 0.", "1, 0., 0."), + "invalid-data-arity", fesa::FailureCategory::input}, + {"nlgeom", replaceOnce(base, "nlgeom=NO", "nlgeom=YES"), + "unsupported-nonlinear-geometry", fesa::FailureCategory::model}, + {"unknown-keyword", replaceOnce(base, "*Assembly, name=Assembly", "*Density\n1.\n*Assembly, name=Assembly"), + "unsupported-keyword", fesa::FailureCategory::input}, + {"invalid-static-arity", replaceOnce(base, "0.1, 1., 0.01, 1.", "0.1, 1., 0.01"), + "invalid-static-data", fesa::FailureCategory::input}, + {"invalid-static-range", replaceOnce(base, "0.1, 1., 0.01, 1.", "0.1, 1., 2., 1."), + "invalid-static-data", fesa::FailureCategory::input}, + {"invalid-generate", replaceOnce(base, "*Elset, elset=BeamSet\n1", "*Elset, elset=BeamSet, generate\n1, 1, 0"), + "invalid-set-range", fesa::FailureCategory::input}, + {"nonlanding-generate", replaceOnce(base, "*Elset, elset=BeamSet\n1", "*Elset, elset=BeamSet, generate\n1, 2, 2"), + "invalid-set-range", fesa::FailureCategory::input}, + {"ambiguous-direct-label", replaceOnce( + replaceOnce(base, + "*End Instance\n*Nset, nset=Root", + "*End Instance\n*Instance, name=Beam-2, part=BeamPart\n*End Instance\n*Nset, nset=Root"), + "Root, 1, 6", + "1, 1, 6"), + "unresolved-reference", fesa::FailureCategory::input}, + {"ambiguous-part-set", replaceOnce( + replaceOnce( + replaceOnce(base, + "*Elset, elset=BeamSet", + "*Nset, nset=Local\n1\n*Elset, elset=BeamSet"), + "*End Instance\n*Nset, nset=Root", + "*End Instance\n*Instance, name=Beam-2, part=BeamPart\n*End Instance\n*Nset, nset=Root"), + "Root, 1, 6", + "Local, 1, 6"), + "unresolved-reference", fesa::FailureCategory::input}, + {"direct-set-conflict", replaceOnce(base, + "*Step, name=Load", + "*Boundary\n1, 1, 1, 2.\n*Step, name=Load"), + "conflicting-boundary-condition", fesa::FailureCategory::input}, + {"dangling-boundary-target", replaceOnce(base, "Root, 1, 6", "Missing, 1, 6"), + "unresolved-reference", fesa::FailureCategory::input}, + {"conflicting-boundary", replaceOnce(base, "*Step, name=Load", "*Boundary\nRoot, 1, 1, 2.\n*Step, name=Load"), + "conflicting-boundary-condition", fesa::FailureCategory::input}}; + + for (const auto& testCase : cases) { + SCOPED_TRACE(testCase.name); + auto result = mapText("invalid-" + testCase.name, testCase.deck); + ASSERT_FALSE(result.hasValue()); + EXPECT_EQ(result.status().failureCategory(), testCase.category); + const auto* diagnostic = findDiagnostic(result.status(), testCase.expectedCode); + ASSERT_NE(diagnostic, nullptr); + EXPECT_EQ(diagnostic->severity, fesa::Severity::error); + EXPECT_FALSE(diagnostic->location.file.empty()); + EXPECT_GT(diagnostic->location.line, 0U); + } +} + +TEST(InpDomainMapping, RejectsDloadWithoutDistributedLoadObject) { + const auto deck = replaceOnce( + minimalDeck(), + "*Cload\nTip, 2, -1.\n", + "*Dload\nBeamSet, PY, -1.\n"); + auto result = mapText("dload", deck); + + ASSERT_FALSE(result.hasValue()); + EXPECT_EQ(result.status().failureCategory(), fesa::FailureCategory::input); + const auto* diagnostic = findDiagnostic(result.status(), "unsupported-keyword"); + ASSERT_NE(diagnostic, nullptr); + EXPECT_EQ(diagnostic->keyword, "DLOAD"); +}