diff --git a/CMakeLists.txt b/CMakeLists.txt index 50dc7a3..dc3adde 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,18 +24,25 @@ target_link_libraries(fesa_core_module_tests PRIVATE fesa_core) add_executable(fesa_math_module_tests tests/test_math_module_includes.cpp) target_link_libraries(fesa_math_module_tests PRIVATE fesa_core) +add_executable(fesa_io_module_tests tests/test_io_module_includes.cpp) +target_link_libraries(fesa_io_module_tests PRIVATE fesa_core) +target_compile_definitions(fesa_io_module_tests PRIVATE FESA_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + if(MSVC) target_compile_options(fesa_core PRIVATE /W4 /permissive-) target_compile_options(fesa_tests PRIVATE /W4 /permissive-) target_compile_options(fesa_core_module_tests PRIVATE /W4 /permissive-) target_compile_options(fesa_math_module_tests PRIVATE /W4 /permissive-) + target_compile_options(fesa_io_module_tests PRIVATE /W4 /permissive-) else() target_compile_options(fesa_core PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(fesa_tests PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(fesa_core_module_tests PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(fesa_math_module_tests PRIVATE -Wall -Wextra -Wpedantic) + target_compile_options(fesa_io_module_tests PRIVATE -Wall -Wextra -Wpedantic) endif() add_test(NAME fesa_tests COMMAND fesa_tests) add_test(NAME fesa_core_module_tests COMMAND fesa_core_module_tests) add_test(NAME fesa_math_module_tests COMMAND fesa_math_module_tests) +add_test(NAME fesa_io_module_tests COMMAND fesa_io_module_tests) diff --git a/PLAN.md b/PLAN.md index d72d168..425affd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,7 +13,7 @@ Every new agent session must read this file together with `PROGRESS.md` before p - If an item becomes obsolete, move it to `PROGRESS.md` with a short reason instead of silently deleting it. ## Current Objective -Execute the Phase 1 structure-alignment refactor in `phases/1-structure-alignment-refactor`, continuing with P1A-04 IO parser extraction. P1A-00 completed the architecture drift audit, P1A-01 created the module scaffold, P1A-02 extracted Core/Util plus Phase 1 Boundary/Load/Property model ownership, and P1A-03 extracted Math primitives plus the solver adapter boundary without changing solver behavior. This phase must align the current monolithic `include/fesa/fesa.hpp` implementation with the module ownership model in `docs/ARCHITECTURE.md` without changing solver behavior. Product-level Phase 1 reference gaps R-010 and R-013 remain open and must not be hidden by the refactor. +Execute the Phase 1 structure-alignment refactor in `phases/1-structure-alignment-refactor`, continuing with P1A-05 Results/reference extraction. P1A-00 completed the architecture drift audit, P1A-01 created the module scaffold, P1A-02 extracted Core/Util plus Phase 1 Boundary/Load/Property model ownership, P1A-03 extracted Math primitives plus the solver adapter boundary, and P1A-04 extracted the Abaqus Phase 1 parser into IO without changing solver behavior. This phase must align the current monolithic `include/fesa/fesa.hpp` implementation with the module ownership model in `docs/ARCHITECTURE.md` without changing solver behavior. Product-level Phase 1 reference gaps R-010 and R-013 remain open and must not be hidden by the refactor. ## Required Reading For New Agents 1. `AGENTS.md` @@ -37,7 +37,7 @@ Execute the Phase 1 structure-alignment refactor in `phases/1-structure-alignmen ## Phase Files - Active phase directory: `phases/1-structure-alignment-refactor` - Execute with: `python scripts/execute.py 1-structure-alignment-refactor` -- Step numbering is zero-based. `step0.md` is complete and wrote `phases/1-structure-alignment-refactor/step0-architecture-map.md`; `step1.md` is complete and created module scaffold headers, source directories, CMake source discovery, and umbrella compatibility smoke coverage; `step2.md` is complete and extracted Core/Util domain, diagnostics, DofManager ownership, AnalysisModel/AnalysisState, and Phase 1 Boundary/Load/Property model ownership; `step3.md` is complete and extracted Math primitives, sparse pattern data, dense matrix support, and solver adapter boundary; `step4.md` extracts the Abaqus parser into IO; `step5.md` extracts Results and reference comparison code; `step6.md` extracts MITC4 geometry/strain helpers; `step7.md` extracts MITC4 material/stiffness helpers; `step8.md` extracts Assembly and Analysis workflow; `step9.md` is the independent architecture evaluator closeout. +- Step numbering is zero-based. `step0.md` is complete and wrote `phases/1-structure-alignment-refactor/step0-architecture-map.md`; `step1.md` is complete and created module scaffold headers, source directories, CMake source discovery, and umbrella compatibility smoke coverage; `step2.md` is complete and extracted Core/Util domain, diagnostics, DofManager ownership, AnalysisModel/AnalysisState, and Phase 1 Boundary/Load/Property model ownership; `step3.md` is complete and extracted Math primitives, sparse pattern data, dense matrix support, and solver adapter boundary; `step4.md` is complete and extracted the Abaqus parser into IO; `step5.md` extracts Results and reference comparison code; `step6.md` extracts MITC4 geometry/strain helpers; `step7.md` extracts MITC4 material/stiffness helpers; `step8.md` extracts Assembly and Analysis workflow; `step9.md` is the independent architecture evaluator closeout. - Completed phase directory: `phases/1-linear-static-mitc4-rebaseline` - Historical execution command: `python scripts/execute.py 1-linear-static-mitc4-rebaseline` - Step numbering is zero-based. `step0.md` is complete and recorded in `phases/1-linear-static-mitc4-rebaseline/step0-audit.md`; `step1.md` is complete and created the `quad_02_phase1.inp` normalized reference path; `step2.md` is complete and revalidated core harness guardrails; `step3.md` is complete and revalidated the Phase 1 parser/domain subset; `step4.md` is complete and strengthened validation/singular diagnostics; `step5.md` is complete and revalidated the DofManager/reaction foundation; `step6.md` is complete and revalidated the minimum result model plus displacement CSV comparator; `step7.md` is complete and revalidated MITC4 natural coordinates, tying points, center directors, and integration bases; `step8.md` is complete and revalidated degenerated-continuum displacement, direct covariant strain rows, and MITC shear tying rows; `step9.md` is complete and revalidated plane-stress material, convected-to-local transform, and `2 x 2 x 2` material integration scaffolding; `step10.md` is complete and revalidated MITC4 stiffness, internal force, six-DOF transform, and drilling stabilization; `step11.md` is complete and added MITC4 membrane, bending, shear, twist, drilling-sensitivity, and thin-cantilever locking-sensitivity tests; `step12.md` is complete and revalidated full-space assembly, reduced projection, deterministic sparse-pattern scaffold, solver adapter injection, and full-vector internal/reaction force state; `step13.md` is complete and revalidated active AnalysisModel construction plus input-to-AnalysisState-to-U/RF result workflow; `step14.md` is complete and added the first stored Abaqus displacement regression for `quad_02_phase1`; `step15.md` is complete and recorded the independent evaluator closeout in `phases/1-linear-static-mitc4-rebaseline/step15-evaluator-report.md`. @@ -61,7 +61,7 @@ This phase is an architecture-preserving refactor. It must not change Phase 1 so | P1A-01 | completed | generator | Create module directory scaffold, CMake source boundaries, and umbrella facade policy. | P1A-00 | Module include smoke tests and build stability | | P1A-02 | completed | generator | Extract Core/Util domain, diagnostics, aliases, DOF mapping, `AnalysisModel`, `DofManager`, and Phase 1 Boundary/Load/Property model ownership. | P1A-01 | Core has no dependency on higher layers; Boundary/Load/Property types are no longer hidden in the umbrella header; DOF tests unchanged | | P1A-03 | completed | generator | Extract Math and solver adapter boundaries. | P1A-02 | Linear solver interface remains adapter-ready; int64 paths unchanged | -| P1A-04 | pending | generator | Extract Abaqus parser into IO. | P1A-02 | Parser subset and unsupported-feature diagnostics unchanged | +| P1A-04 | completed | generator | Extract Abaqus parser into IO. | P1A-02 | Parser subset and unsupported-feature diagnostics unchanged | | P1A-05 | pending | generator | Extract Results model, writer boundary, CSV loader, and reference comparator. | P1A-02, P1A-04 | `U`/`RF` schema and `quad_02_phase1` regression unchanged | | P1A-06 | pending | generator | Extract MITC4 geometry, director, strain, and tying helpers into Element. | P1A-03 | Geometry/strain tests and formulation signs unchanged | | P1A-07 | pending | generator | Extract MITC4 material, integration, stiffness, drilling, and internal-force helpers. | P1A-06 | Patch, drilling, stiffness, and locking-sensitivity tests unchanged | diff --git a/PROGRESS.md b/PROGRESS.md index 20908dc..d23e365 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,10 +13,41 @@ Every new agent session must read this file together with `PLAN.md` before plann - Do not remove history unless the user explicitly asks for archival cleanup. ## Current Status -Phase 1 has a completed rebaseline execution path in `phases/1-linear-static-mitc4-rebaseline`. Steps 0 through 15 are complete, and P1R-15 recorded a pass-with-documented-gaps evaluator closeout. The follow-up architecture refactor phase in `phases/1-structure-alignment-refactor` is underway because the current production implementation is concentrated in `include/fesa/fesa.hpp` instead of the module directories documented in `docs/ARCHITECTURE.md`; P1A-00 through P1A-03 are complete, so the next step is P1A-04 IO parser extraction. `quad_02_phase1.inp` is the normalized Phase 1-compatible input path for the stored `quad_02` S4 reference pair, while the original `quad_02.inp` remains preserved unsupported provenance. Core numeric aliases, DOF mapping, validation harness, model diagnostic context, the Phase 1 parser/domain subset, validation/singular diagnostics, DofManager/reaction foundation, minimum result model metadata, displacement CSV comparator foundation, MITC4 geometry/director scaffolding, MITC4 displacement/strain/tying row scaffolding, MITC4 material/transform/integration scaffolding, MITC4 stiffness/drilling/internal-force scaffolding, MITC4 patch/locking-sensitivity tests, full-space assembly, reduced projection, sparse-pattern scaffold, solver adapter injection, full-vector internal/reaction force state, active AnalysisModel construction, input-to-AnalysisState-to-U/RF result workflow, and the first stored Abaqus displacement regression have been revalidated. Full PRD Phase 1 completion still depends on the open architecture/reference gaps R-014, R-010, and R-013. The old `phases/1-linear-static-mitc4` path is historical and superseded after the MITC4 formulation reset. +Phase 1 has a completed rebaseline execution path in `phases/1-linear-static-mitc4-rebaseline`. Steps 0 through 15 are complete, and P1R-15 recorded a pass-with-documented-gaps evaluator closeout. The follow-up architecture refactor phase in `phases/1-structure-alignment-refactor` is underway because the current production implementation is concentrated in `include/fesa/fesa.hpp` instead of the module directories documented in `docs/ARCHITECTURE.md`; P1A-00 through P1A-04 are complete, so the next step is P1A-05 Results/reference extraction. `quad_02_phase1.inp` is the normalized Phase 1-compatible input path for the stored `quad_02` S4 reference pair, while the original `quad_02.inp` remains preserved unsupported provenance. Core numeric aliases, DOF mapping, validation harness, model diagnostic context, the Phase 1 parser/domain subset, validation/singular diagnostics, DofManager/reaction foundation, minimum result model metadata, displacement CSV comparator foundation, MITC4 geometry/director scaffolding, MITC4 displacement/strain/tying row scaffolding, MITC4 material/transform/integration scaffolding, MITC4 stiffness/drilling/internal-force scaffolding, MITC4 patch/locking-sensitivity tests, full-space assembly, reduced projection, sparse-pattern scaffold, solver adapter injection, full-vector internal/reaction force state, active AnalysisModel construction, input-to-AnalysisState-to-U/RF result workflow, and the first stored Abaqus displacement regression have been revalidated. Full PRD Phase 1 completion still depends on the open architecture/reference gaps R-014, R-010, and R-013. The old `phases/1-linear-static-mitc4` path is historical and superseded after the MITC4 formulation reset. ## Completed Work +### 2026-05-05 - P1A-04 IO parser extraction completed +Author: Codex + +Changed files: +- `CMakeLists.txt` +- `include/fesa/IO/AbaqusInputParser.hpp` +- `include/fesa/IO/IO.hpp` +- `include/fesa/fesa.hpp` +- `tests/test_io_module_includes.cpp` +- `phases/1-structure-alignment-refactor/index.json` +- `PLAN.md` +- `PROGRESS.md` + +Summary: +- Extracted `KeywordLine`, `ParseResult`, `parseKeywordLine`, and `AbaqusInputParser` from the umbrella header into `include/fesa/IO/AbaqusInputParser.hpp`. +- Updated `include/fesa/IO/IO.hpp` to expose the parser through the IO module while depending only on Core and Util. +- Updated `include/fesa/fesa.hpp` to include the IO module facade, preserving existing public symbols for umbrella consumers. +- Added `fesa_io_module_tests`, a direct IO include smoke/regression test that does not include `fesa/fesa.hpp`. +- Verified that `references/quad_02_phase1.inp` remains accepted with expected node, element, set, material, and shell-section counts. +- Verified that the original unsupported `references/quad_02.inp` still fails with unsupported-keyword diagnostics, and that nonzero prescribed displacement still reports `FESA-PARSE-BOUNDARY-NONZERO`. +- Remaining large groups in `fesa.hpp` are Assembly helpers (`buildReducedSparsePattern`, `recoverFullReaction`), MITC4 Element/Material helpers, Results/reference comparison, and Analysis workflow. + +Verification: +- First ran `python scripts\validate_workspace.py` after adding the direct IO include test; it failed as expected because `fesa/IO/IO.hpp` did not yet expose parser symbols. +- After extraction, `python scripts\validate_workspace.py` configured CMake, built `fesa_core`, `fesa_tests`, `fesa_core_module_tests`, `fesa_math_module_tests`, and `fesa_io_module_tests`, and ran CTest successfully. +- CTest result: 4 test executables passed. + +Follow-up: +- Continue with P1A-05 Results/reference extraction. +- Keep R-014 open until P1A-09 independently accepts the final architecture alignment. + ### 2026-05-05 - P1A-03 Math solver extraction completed Author: Codex diff --git a/include/fesa/IO/AbaqusInputParser.hpp b/include/fesa/IO/AbaqusInputParser.hpp new file mode 100644 index 0000000..a160ac0 --- /dev/null +++ b/include/fesa/IO/AbaqusInputParser.hpp @@ -0,0 +1,542 @@ +#pragma once + +#include "fesa/Core/Core.hpp" +#include "fesa/Util/Util.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { + +struct KeywordLine { + std::string name; + std::map parameters; + std::set flags; +}; + +inline KeywordLine parseKeywordLine(const std::string& line) { + KeywordLine keyword; + std::vector pieces = splitCsv(line.substr(1)); + if (pieces.empty()) { + return keyword; + } + keyword.name = lower(trim(pieces.front())); + for (std::size_t i = 1; i < pieces.size(); ++i) { + const std::string piece = trim(pieces[i]); + if (piece.empty()) { + continue; + } + const auto eq = piece.find('='); + if (eq == std::string::npos) { + keyword.flags.insert(lower(piece)); + } else { + keyword.parameters[lower(trim(piece.substr(0, eq)))] = trim(piece.substr(eq + 1)); + } + } + return keyword; +} + +struct ParseResult { + Domain domain; + std::vector diagnostics; + + bool ok() const { + return !hasError(diagnostics); + } +}; + +class AbaqusInputParser { + public: + ParseResult parseString(const std::string& text, const std::string& file_name = "") const { + ParseResult result; + std::istringstream stream(text); + std::string line; + KeywordLine current; + std::string current_material_key; + KeywordLine current_shell_section; + LocalIndex line_number = 0; + LocalIndex current_keyword_line = 0; + + auto add_error = [&](const std::string& code, const std::string& message) { + const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; + result.diagnostics.push_back({Severity::Error, code, message, {file_name, source_line, current.name}}); + }; + auto is_allowed = [](const std::string& value, std::initializer_list allowed_values) { + return std::any_of(allowed_values.begin(), allowed_values.end(), [&](const char* allowed) { + return value == allowed; + }); + }; + auto reject_unsupported_controls = [&](std::initializer_list allowed_parameters, + std::initializer_list allowed_flags) { + for (const auto& [parameter, value] : current.parameters) { + (void)value; + if (!is_allowed(parameter, allowed_parameters)) { + const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-UNSUPPORTED-PARAMETER", + "Unsupported *" + current.name + " parameter: " + parameter, + {file_name, source_line, current.name}}); + } + } + for (const std::string& flag : current.flags) { + if (!is_allowed(flag, allowed_flags)) { + const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-UNSUPPORTED-PARAMETER", + "Unsupported *" + current.name + " flag: " + flag, + {file_name, source_line, current.name}}); + } + } + }; + + while (std::getline(stream, line)) { + ++line_number; + line = trim(line); + if (line.empty() || line.rfind("**", 0) == 0) { + continue; + } + if (!line.empty() && line.front() == '*') { + current_keyword_line = line_number; + std::string keyword_line = line; + while (!keyword_line.empty() && keyword_line.back() == ',') { + std::string continuation; + if (!std::getline(stream, continuation)) { + break; + } + ++line_number; + continuation = trim(continuation); + if (continuation.empty() || continuation.rfind("**", 0) == 0) { + continue; + } + keyword_line += continuation; + } + current = parseKeywordLine(keyword_line); + if (current.name == "node") { + reject_unsupported_controls({}, {}); + continue; + } + if (current.name == "element") { + reject_unsupported_controls({"type", "elset"}, {}); + continue; + } + if (current.name == "nset") { + reject_unsupported_controls({"nset"}, {"generate"}); + continue; + } + if (current.name == "elset") { + reject_unsupported_controls({"elset"}, {"generate"}); + continue; + } + if (current.name == "elastic") { + reject_unsupported_controls({}, {}); + continue; + } + if (current.name == "shell section") { + reject_unsupported_controls({"elset", "material"}, {}); + current_shell_section = current; + continue; + } + if (current.name == "boundary" || current.name == "cload" || current.name == "static") { + reject_unsupported_controls({}, {}); + continue; + } + if (current.name == "material") { + reject_unsupported_controls({"name"}, {}); + auto name_it = current.parameters.find("name"); + if (name_it == current.parameters.end() || trim(name_it->second).empty()) { + add_error("FESA-PARSE-MATERIAL-NAME", "*Material requires NAME"); + current_material_key.clear(); + continue; + } + Material material; + material.name = trim(name_it->second); + current_material_key = Domain::key(material.name); + if (result.domain.materials.count(current_material_key) != 0) { + add_error("FESA-PARSE-DUPLICATE-MATERIAL", "Duplicate material: " + material.name); + } else { + result.domain.materials[current_material_key] = material; + } + continue; + } + if (current.name == "step") { + reject_unsupported_controls({"name", "nlgeom"}, {}); + auto nlgeom = current.parameters.find("nlgeom"); + if (nlgeom != current.parameters.end() && lower(trim(nlgeom->second)) == "yes") { + add_error("FESA-PARSE-UNSUPPORTED-NLGEOM", "NLGEOM=YES is not supported in Phase 1"); + } + StepDefinition step; + auto name_it = current.parameters.find("name"); + if (name_it != current.parameters.end() && !trim(name_it->second).empty()) { + step.name = trim(name_it->second); + } + result.domain.steps.push_back(step); + continue; + } + if (current.name == "end step") { + reject_unsupported_controls({}, {}); + continue; + } + add_error("FESA-PARSE-UNSUPPORTED-KEYWORD", "Unsupported keyword: *" + current.name); + continue; + } + + const std::vector fields = splitCsv(line); + if (current.name == "node") { + parseNode(fields, result, file_name, line_number); + } else if (current.name == "element") { + parseElement(fields, current, result, file_name, line_number); + } else if (current.name == "nset") { + parseNodeSet(fields, current, result, file_name, line_number); + } else if (current.name == "elset") { + parseElementSet(fields, current, result, file_name, line_number); + } else if (current.name == "elastic") { + parseElastic(fields, current_material_key, result, file_name, line_number); + } else if (current.name == "shell section") { + parseShellSection(fields, current_shell_section, result, file_name, line_number); + } else if (current.name == "boundary") { + parseBoundary(fields, result, file_name, line_number); + } else if (current.name == "cload") { + parseLoad(fields, result, file_name, line_number); + } + } + + if (result.domain.steps.empty()) { + result.domain.steps.push_back({"Step-1", "linear_static"}); + } + return result; + } + + ParseResult parseFile(const std::string& path) const { + std::ifstream input(path); + std::ostringstream buffer; + buffer << input.rdbuf(); + ParseResult result = parseString(buffer.str(), path); + if (!input.good() && buffer.str().empty()) { + result.diagnostics.push_back({Severity::Error, "FESA-PARSE-FILE", "Could not read input file", {path, 0, ""}}); + } + return result; + } + + private: + static std::size_t effectiveFieldCount(const std::vector& fields) { + std::size_t count = fields.size(); + while (count > 0 && trim(fields[count - 1]).empty()) { + --count; + } + return count; + } + + static void parseNode(const std::vector& fields, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + if (effectiveFieldCount(fields) != 4) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-NODE", "*Node data requires id,x,y,z", {file_name, line, "node"}}); + return; + } + auto id = parseInt64(fields[0]); + auto x = parseReal(fields[1]); + auto y = parseReal(fields[2]); + auto z = parseReal(fields[3]); + if (!id || !x || !y || !z) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-NODE-NUMERIC", "Invalid node numeric field", {file_name, line, "node"}}); + return; + } + if (result.domain.nodes.count(*id) != 0) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-DUPLICATE-NODE", "Duplicate node id", {file_name, line, "node"}}); + return; + } + result.domain.nodes[*id] = {*id, {*x, *y, *z}}; + } + + static void parseElement(const std::vector& fields, + const KeywordLine& keyword, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + auto type_it = keyword.parameters.find("type"); + if (type_it == keyword.parameters.end()) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-ELEMENT-TYPE", "*Element requires TYPE", {file_name, line, "element"}}); + return; + } + const std::string type = lower(trim(type_it->second)); + if (type != "s4") { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-UNSUPPORTED-ELEMENT", + "Unsupported element type: " + type_it->second, + {file_name, line, "element"}}); + return; + } + if (effectiveFieldCount(fields) != 5) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-ELEMENT", + "S4 element requires id,n1,n2,n3,n4", + {file_name, line, "element"}}); + return; + } + auto id = parseInt64(fields[0]); + std::array nodes{}; + bool ok = id.has_value(); + for (int i = 0; i < 4; ++i) { + auto node = parseInt64(fields[1 + static_cast(i)]); + ok = ok && node.has_value(); + if (node) { + nodes[static_cast(i)] = *node; + } + } + if (!ok) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-ELEMENT-NUMERIC", "Invalid element numeric field", {file_name, line, "element"}}); + return; + } + if (result.domain.elements.count(*id) != 0) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-DUPLICATE-ELEMENT", "Duplicate element id", {file_name, line, "element"}}); + return; + } + Element element; + element.id = *id; + element.node_ids = nodes; + auto elset_it = keyword.parameters.find("elset"); + if (elset_it != keyword.parameters.end()) { + element.source_elset = trim(elset_it->second); + auto& set = result.domain.element_sets[Domain::key(element.source_elset)]; + set.name = element.source_elset; + addUnique(set.element_ids, *id); + } + result.domain.elements[*id] = element; + } + + static void parseNodeSet(const std::vector& fields, + const KeywordLine& keyword, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + auto name_it = keyword.parameters.find("nset"); + if (name_it == keyword.parameters.end()) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-NSET-NAME", "*Nset requires NSET", {file_name, line, "nset"}}); + return; + } + auto& set = result.domain.node_sets[Domain::key(name_it->second)]; + set.name = trim(name_it->second); + parseSetData(fields, keyword.flags.count("generate") != 0, set.node_ids, result.diagnostics, file_name, line, "nset"); + } + + static void parseElementSet(const std::vector& fields, + const KeywordLine& keyword, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + auto name_it = keyword.parameters.find("elset"); + if (name_it == keyword.parameters.end()) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-ELSET-NAME", "*Elset requires ELSET", {file_name, line, "elset"}}); + return; + } + auto& set = result.domain.element_sets[Domain::key(name_it->second)]; + set.name = trim(name_it->second); + parseSetData(fields, keyword.flags.count("generate") != 0, set.element_ids, result.diagnostics, file_name, line, "elset"); + } + + static void parseSetData(const std::vector& fields, + bool generate, + std::vector& output, + std::vector& diagnostics, + const std::string& file_name, + LocalIndex line, + const std::string& keyword) { + if (generate) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count != 3) { + diagnostics.push_back({Severity::Error, + "FESA-PARSE-GENERATE", + "Generated set requires first,last,increment", + {file_name, line, keyword}}); + return; + } + auto first = parseInt64(fields[0]); + auto last = parseInt64(fields[1]); + auto increment = parseInt64(fields[2]); + if (!first || !last || !increment || *increment <= 0) { + diagnostics.push_back( + {Severity::Error, "FESA-PARSE-GENERATE", "Invalid generated set range", {file_name, line, keyword}}); + return; + } + for (GlobalId value : generatedRange(*first, *last, *increment)) { + addUnique(output, value); + } + return; + } + const std::size_t field_count = effectiveFieldCount(fields); + for (std::size_t i = 0; i < field_count; ++i) { + const std::string& field = fields[i]; + if (trim(field).empty()) { + continue; + } + auto value = parseInt64(field); + if (!value) { + diagnostics.push_back( + {Severity::Error, "FESA-PARSE-SET-NUMERIC", "Invalid set id", {file_name, line, keyword}}); + return; + } + addUnique(output, *value); + } + } + + static void parseElastic(const std::vector& fields, + const std::string& material_key, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + if (material_key.empty() || result.domain.materials.count(material_key) == 0) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-ELASTIC-MATERIAL", "*Elastic must follow *Material", {file_name, line, "elastic"}}); + return; + } + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count < 2) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-ELASTIC", "*Elastic requires E,nu", {file_name, line, "elastic"}}); + return; + } + if (field_count > 2) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-ELASTIC-UNSUPPORTED", + "Only isotropic E,nu elastic data is supported", + {file_name, line, "elastic"}}); + return; + } + auto e = parseReal(fields[0]); + auto nu = parseReal(fields[1]); + if (!e || !nu || *e <= 0.0 || *nu <= -1.0 || *nu >= 0.5) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-ELASTIC-RANGE", + "Invalid isotropic elastic constants", + {file_name, line, "elastic"}}); + return; + } + result.domain.materials[material_key].elastic_modulus = *e; + result.domain.materials[material_key].poisson_ratio = *nu; + } + + static void parseShellSection(const std::vector& fields, + const KeywordLine& keyword, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + auto elset_it = keyword.parameters.find("elset"); + auto material_it = keyword.parameters.find("material"); + if (elset_it == keyword.parameters.end() || material_it == keyword.parameters.end()) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-SHELL-SECTION-PARAM", + "*Shell Section requires ELSET and MATERIAL", + {file_name, line, "shell section"}}); + return; + } + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count == 0) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-SHELL-SECTION", + "*Shell Section requires thickness", + {file_name, line, "shell section"}}); + return; + } + if (field_count > 1) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-SHELL-SECTION-UNSUPPORTED", + "Only homogeneous shell thickness data is supported", + {file_name, line, "shell section"}}); + return; + } + auto thickness = parseReal(fields[0]); + if (!thickness || *thickness <= 0.0) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-SHELL-THICKNESS", + "Shell thickness must be positive", + {file_name, line, "shell section"}}); + return; + } + result.domain.shell_sections.push_back({trim(elset_it->second), trim(material_it->second), *thickness}); + } + + static void parseBoundary(const std::vector& fields, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count < 2) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-BOUNDARY", "*Boundary requires target,first_dof", {file_name, line, "boundary"}}); + return; + } + if (field_count > 4) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-BOUNDARY-UNSUPPORTED", + "Only direct zero-valued boundary data is supported", + {file_name, line, "boundary"}}); + return; + } + auto first = parseInt64(fields[1]); + auto last = field_count >= 3 && !fields[2].empty() ? parseInt64(fields[2]) : first; + auto magnitude = field_count >= 4 && !fields[3].empty() ? parseReal(fields[3]) : std::optional(0.0); + if (!first || !last || !magnitude || !dofFromAbaqus(static_cast(*first)) || + !dofFromAbaqus(static_cast(*last)) || *first > *last) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-BOUNDARY-DOF", + "Invalid boundary DOF range", + {file_name, line, "boundary"}}); + return; + } + if (std::fabs(*magnitude) > 0.0) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-BOUNDARY-NONZERO", + "Nonzero prescribed displacement is not supported in Phase 1", + {file_name, line, "boundary"}}); + return; + } + result.domain.boundary_conditions.push_back({trim(fields[0]), static_cast(*first), static_cast(*last), *magnitude}); + } + + static void parseLoad(const std::vector& fields, + ParseResult& result, + const std::string& file_name, + LocalIndex line) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count < 3) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-CLOAD", "*Cload requires target,dof,magnitude", {file_name, line, "cload"}}); + return; + } + if (field_count > 3) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-CLOAD-UNSUPPORTED", + "Only direct concentrated load data is supported", + {file_name, line, "cload"}}); + return; + } + auto dof = parseInt64(fields[1]); + auto magnitude = parseReal(fields[2]); + if (!dof || !magnitude || !dofFromAbaqus(static_cast(*dof))) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-CLOAD-DOF", "Invalid concentrated load", {file_name, line, "cload"}}); + return; + } + result.domain.loads.push_back({trim(fields[0]), static_cast(*dof), *magnitude}); + } +}; + +} // namespace fesa diff --git a/include/fesa/IO/IO.hpp b/include/fesa/IO/IO.hpp index 80e0230..2a440ea 100644 --- a/include/fesa/IO/IO.hpp +++ b/include/fesa/IO/IO.hpp @@ -1,5 +1,6 @@ #pragma once +#include "fesa/IO/AbaqusInputParser.hpp" #include "fesa/ModuleInfo.hpp" namespace fesa::module { diff --git a/include/fesa/fesa.hpp b/include/fesa/fesa.hpp index caabaf7..07b7a79 100644 --- a/include/fesa/fesa.hpp +++ b/include/fesa/fesa.hpp @@ -2,6 +2,7 @@ #include "fesa/Boundary/Boundary.hpp" #include "fesa/Core/Core.hpp" +#include "fesa/IO/IO.hpp" #include "fesa/Load/Load.hpp" #include "fesa/Math/Math.hpp" #include "fesa/ModuleInfo.hpp" @@ -28,449 +29,6 @@ namespace fesa { -struct KeywordLine { - std::string name; - std::map parameters; - std::set flags; -}; - -inline KeywordLine parseKeywordLine(const std::string& line) { - KeywordLine keyword; - std::vector pieces = splitCsv(line.substr(1)); - if (pieces.empty()) { - return keyword; - } - keyword.name = lower(trim(pieces.front())); - for (std::size_t i = 1; i < pieces.size(); ++i) { - const std::string piece = trim(pieces[i]); - if (piece.empty()) { - continue; - } - const auto eq = piece.find('='); - if (eq == std::string::npos) { - keyword.flags.insert(lower(piece)); - } else { - keyword.parameters[lower(trim(piece.substr(0, eq)))] = trim(piece.substr(eq + 1)); - } - } - return keyword; -} - -struct ParseResult { - Domain domain; - std::vector diagnostics; - - bool ok() const { - return !hasError(diagnostics); - } -}; - -class AbaqusInputParser { - public: - ParseResult parseString(const std::string& text, const std::string& file_name = "") const { - ParseResult result; - std::istringstream stream(text); - std::string line; - KeywordLine current; - std::string current_material_key; - KeywordLine current_shell_section; - LocalIndex line_number = 0; - LocalIndex current_keyword_line = 0; - - auto add_error = [&](const std::string& code, const std::string& message) { - const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; - result.diagnostics.push_back({Severity::Error, code, message, {file_name, source_line, current.name}}); - }; - auto is_allowed = [](const std::string& value, std::initializer_list allowed_values) { - return std::any_of(allowed_values.begin(), allowed_values.end(), [&](const char* allowed) { - return value == allowed; - }); - }; - auto reject_unsupported_controls = [&](std::initializer_list allowed_parameters, - std::initializer_list allowed_flags) { - for (const auto& [parameter, value] : current.parameters) { - (void)value; - if (!is_allowed(parameter, allowed_parameters)) { - const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; - result.diagnostics.push_back({Severity::Error, - "FESA-PARSE-UNSUPPORTED-PARAMETER", - "Unsupported *" + current.name + " parameter: " + parameter, - {file_name, source_line, current.name}}); - } - } - for (const std::string& flag : current.flags) { - if (!is_allowed(flag, allowed_flags)) { - const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; - result.diagnostics.push_back({Severity::Error, - "FESA-PARSE-UNSUPPORTED-PARAMETER", - "Unsupported *" + current.name + " flag: " + flag, - {file_name, source_line, current.name}}); - } - } - }; - - while (std::getline(stream, line)) { - ++line_number; - line = trim(line); - if (line.empty() || line.rfind("**", 0) == 0) { - continue; - } - if (!line.empty() && line.front() == '*') { - current_keyword_line = line_number; - std::string keyword_line = line; - while (!keyword_line.empty() && keyword_line.back() == ',') { - std::string continuation; - if (!std::getline(stream, continuation)) { - break; - } - ++line_number; - continuation = trim(continuation); - if (continuation.empty() || continuation.rfind("**", 0) == 0) { - continue; - } - keyword_line += continuation; - } - current = parseKeywordLine(keyword_line); - if (current.name == "node") { - reject_unsupported_controls({}, {}); - continue; - } - if (current.name == "element") { - reject_unsupported_controls({"type", "elset"}, {}); - continue; - } - if (current.name == "nset") { - reject_unsupported_controls({"nset"}, {"generate"}); - continue; - } - if (current.name == "elset") { - reject_unsupported_controls({"elset"}, {"generate"}); - continue; - } - if (current.name == "elastic") { - reject_unsupported_controls({}, {}); - continue; - } - if (current.name == "shell section") { - reject_unsupported_controls({"elset", "material"}, {}); - current_shell_section = current; - continue; - } - if (current.name == "boundary" || current.name == "cload" || current.name == "static") { - reject_unsupported_controls({}, {}); - continue; - } - if (current.name == "material") { - reject_unsupported_controls({"name"}, {}); - auto name_it = current.parameters.find("name"); - if (name_it == current.parameters.end() || trim(name_it->second).empty()) { - add_error("FESA-PARSE-MATERIAL-NAME", "*Material requires NAME"); - current_material_key.clear(); - continue; - } - Material material; - material.name = trim(name_it->second); - current_material_key = Domain::key(material.name); - if (result.domain.materials.count(current_material_key) != 0) { - add_error("FESA-PARSE-DUPLICATE-MATERIAL", "Duplicate material: " + material.name); - } else { - result.domain.materials[current_material_key] = material; - } - continue; - } - if (current.name == "step") { - reject_unsupported_controls({"name", "nlgeom"}, {}); - auto nlgeom = current.parameters.find("nlgeom"); - if (nlgeom != current.parameters.end() && lower(trim(nlgeom->second)) == "yes") { - add_error("FESA-PARSE-UNSUPPORTED-NLGEOM", "NLGEOM=YES is not supported in Phase 1"); - } - StepDefinition step; - auto name_it = current.parameters.find("name"); - if (name_it != current.parameters.end() && !trim(name_it->second).empty()) { - step.name = trim(name_it->second); - } - result.domain.steps.push_back(step); - continue; - } - if (current.name == "end step") { - reject_unsupported_controls({}, {}); - continue; - } - add_error("FESA-PARSE-UNSUPPORTED-KEYWORD", "Unsupported keyword: *" + current.name); - continue; - } - - const std::vector fields = splitCsv(line); - if (current.name == "node") { - parseNode(fields, result, file_name, line_number); - } else if (current.name == "element") { - parseElement(fields, current, result, file_name, line_number); - } else if (current.name == "nset") { - parseNodeSet(fields, current, result, file_name, line_number); - } else if (current.name == "elset") { - parseElementSet(fields, current, result, file_name, line_number); - } else if (current.name == "elastic") { - parseElastic(fields, current_material_key, result, file_name, line_number); - } else if (current.name == "shell section") { - parseShellSection(fields, current_shell_section, result, file_name, line_number); - } else if (current.name == "boundary") { - parseBoundary(fields, result, file_name, line_number); - } else if (current.name == "cload") { - parseLoad(fields, result, file_name, line_number); - } - } - - if (result.domain.steps.empty()) { - result.domain.steps.push_back({"Step-1", "linear_static"}); - } - return result; - } - - ParseResult parseFile(const std::string& path) const { - std::ifstream input(path); - std::ostringstream buffer; - buffer << input.rdbuf(); - ParseResult result = parseString(buffer.str(), path); - if (!input.good() && buffer.str().empty()) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-FILE", "Could not read input file", {path, 0, ""}}); - } - return result; - } - - private: - static std::size_t effectiveFieldCount(const std::vector& fields) { - std::size_t count = fields.size(); - while (count > 0 && trim(fields[count - 1]).empty()) { - --count; - } - return count; - } - - static void parseNode(const std::vector& fields, ParseResult& result, const std::string& file_name, LocalIndex line) { - if (effectiveFieldCount(fields) != 4) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-NODE", "*Node data requires id,x,y,z", {file_name, line, "node"}}); - return; - } - auto id = parseInt64(fields[0]); - auto x = parseReal(fields[1]); - auto y = parseReal(fields[2]); - auto z = parseReal(fields[3]); - if (!id || !x || !y || !z) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-NODE-NUMERIC", "Invalid node numeric field", {file_name, line, "node"}}); - return; - } - if (result.domain.nodes.count(*id) != 0) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-DUPLICATE-NODE", "Duplicate node id", {file_name, line, "node"}}); - return; - } - result.domain.nodes[*id] = {*id, {*x, *y, *z}}; - } - - static void parseElement(const std::vector& fields, const KeywordLine& keyword, ParseResult& result, const std::string& file_name, LocalIndex line) { - auto type_it = keyword.parameters.find("type"); - if (type_it == keyword.parameters.end()) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELEMENT-TYPE", "*Element requires TYPE", {file_name, line, "element"}}); - return; - } - const std::string type = lower(trim(type_it->second)); - if (type != "s4") { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-UNSUPPORTED-ELEMENT", "Unsupported element type: " + type_it->second, {file_name, line, "element"}}); - return; - } - if (effectiveFieldCount(fields) != 5) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELEMENT", "S4 element requires id,n1,n2,n3,n4", {file_name, line, "element"}}); - return; - } - auto id = parseInt64(fields[0]); - std::array nodes{}; - bool ok = id.has_value(); - for (int i = 0; i < 4; ++i) { - auto node = parseInt64(fields[1 + static_cast(i)]); - ok = ok && node.has_value(); - if (node) { - nodes[static_cast(i)] = *node; - } - } - if (!ok) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELEMENT-NUMERIC", "Invalid element numeric field", {file_name, line, "element"}}); - return; - } - if (result.domain.elements.count(*id) != 0) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-DUPLICATE-ELEMENT", "Duplicate element id", {file_name, line, "element"}}); - return; - } - Element element; - element.id = *id; - element.node_ids = nodes; - auto elset_it = keyword.parameters.find("elset"); - if (elset_it != keyword.parameters.end()) { - element.source_elset = trim(elset_it->second); - auto& set = result.domain.element_sets[Domain::key(element.source_elset)]; - set.name = element.source_elset; - addUnique(set.element_ids, *id); - } - result.domain.elements[*id] = element; - } - - static void parseNodeSet(const std::vector& fields, const KeywordLine& keyword, ParseResult& result, const std::string& file_name, LocalIndex line) { - auto name_it = keyword.parameters.find("nset"); - if (name_it == keyword.parameters.end()) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-NSET-NAME", "*Nset requires NSET", {file_name, line, "nset"}}); - return; - } - auto& set = result.domain.node_sets[Domain::key(name_it->second)]; - set.name = trim(name_it->second); - parseSetData(fields, keyword.flags.count("generate") != 0, set.node_ids, result.diagnostics, file_name, line, "nset"); - } - - static void parseElementSet(const std::vector& fields, const KeywordLine& keyword, ParseResult& result, const std::string& file_name, LocalIndex line) { - auto name_it = keyword.parameters.find("elset"); - if (name_it == keyword.parameters.end()) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELSET-NAME", "*Elset requires ELSET", {file_name, line, "elset"}}); - return; - } - auto& set = result.domain.element_sets[Domain::key(name_it->second)]; - set.name = trim(name_it->second); - parseSetData(fields, keyword.flags.count("generate") != 0, set.element_ids, result.diagnostics, file_name, line, "elset"); - } - - static void parseSetData(const std::vector& fields, bool generate, std::vector& output, - std::vector& diagnostics, const std::string& file_name, LocalIndex line, const std::string& keyword) { - if (generate) { - const std::size_t field_count = effectiveFieldCount(fields); - if (field_count != 3) { - diagnostics.push_back({Severity::Error, "FESA-PARSE-GENERATE", "Generated set requires first,last,increment", {file_name, line, keyword}}); - return; - } - auto first = parseInt64(fields[0]); - auto last = parseInt64(fields[1]); - auto increment = parseInt64(fields[2]); - if (!first || !last || !increment || *increment <= 0) { - diagnostics.push_back({Severity::Error, "FESA-PARSE-GENERATE", "Invalid generated set range", {file_name, line, keyword}}); - return; - } - for (GlobalId value : generatedRange(*first, *last, *increment)) { - addUnique(output, value); - } - return; - } - const std::size_t field_count = effectiveFieldCount(fields); - for (std::size_t i = 0; i < field_count; ++i) { - const std::string& field = fields[i]; - if (trim(field).empty()) { - continue; - } - auto value = parseInt64(field); - if (!value) { - diagnostics.push_back({Severity::Error, "FESA-PARSE-SET-NUMERIC", "Invalid set id", {file_name, line, keyword}}); - return; - } - addUnique(output, *value); - } - } - - static void parseElastic(const std::vector& fields, const std::string& material_key, ParseResult& result, const std::string& file_name, LocalIndex line) { - if (material_key.empty() || result.domain.materials.count(material_key) == 0) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELASTIC-MATERIAL", "*Elastic must follow *Material", {file_name, line, "elastic"}}); - return; - } - const std::size_t field_count = effectiveFieldCount(fields); - if (field_count < 2) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELASTIC", "*Elastic requires E,nu", {file_name, line, "elastic"}}); - return; - } - if (field_count > 2) { - result.diagnostics.push_back( - {Severity::Error, "FESA-PARSE-ELASTIC-UNSUPPORTED", "Only isotropic E,nu elastic data is supported", {file_name, line, "elastic"}}); - return; - } - auto e = parseReal(fields[0]); - auto nu = parseReal(fields[1]); - if (!e || !nu || *e <= 0.0 || *nu <= -1.0 || *nu >= 0.5) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELASTIC-RANGE", "Invalid isotropic elastic constants", {file_name, line, "elastic"}}); - return; - } - result.domain.materials[material_key].elastic_modulus = *e; - result.domain.materials[material_key].poisson_ratio = *nu; - } - - static void parseShellSection(const std::vector& fields, const KeywordLine& keyword, ParseResult& result, const std::string& file_name, LocalIndex line) { - auto elset_it = keyword.parameters.find("elset"); - auto material_it = keyword.parameters.find("material"); - if (elset_it == keyword.parameters.end() || material_it == keyword.parameters.end()) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-SHELL-SECTION-PARAM", "*Shell Section requires ELSET and MATERIAL", {file_name, line, "shell section"}}); - return; - } - const std::size_t field_count = effectiveFieldCount(fields); - if (field_count == 0) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-SHELL-SECTION", "*Shell Section requires thickness", {file_name, line, "shell section"}}); - return; - } - if (field_count > 1) { - result.diagnostics.push_back({Severity::Error, - "FESA-PARSE-SHELL-SECTION-UNSUPPORTED", - "Only homogeneous shell thickness data is supported", - {file_name, line, "shell section"}}); - return; - } - auto thickness = parseReal(fields[0]); - if (!thickness || *thickness <= 0.0) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-SHELL-THICKNESS", "Shell thickness must be positive", {file_name, line, "shell section"}}); - return; - } - result.domain.shell_sections.push_back({trim(elset_it->second), trim(material_it->second), *thickness}); - } - - static void parseBoundary(const std::vector& fields, ParseResult& result, const std::string& file_name, LocalIndex line) { - const std::size_t field_count = effectiveFieldCount(fields); - if (field_count < 2) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-BOUNDARY", "*Boundary requires target,first_dof", {file_name, line, "boundary"}}); - return; - } - if (field_count > 4) { - result.diagnostics.push_back({Severity::Error, - "FESA-PARSE-BOUNDARY-UNSUPPORTED", - "Only direct zero-valued boundary data is supported", - {file_name, line, "boundary"}}); - return; - } - auto first = parseInt64(fields[1]); - auto last = field_count >= 3 && !fields[2].empty() ? parseInt64(fields[2]) : first; - auto magnitude = field_count >= 4 && !fields[3].empty() ? parseReal(fields[3]) : std::optional(0.0); - if (!first || !last || !magnitude || !dofFromAbaqus(static_cast(*first)) || !dofFromAbaqus(static_cast(*last)) || *first > *last) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-BOUNDARY-DOF", "Invalid boundary DOF range", {file_name, line, "boundary"}}); - return; - } - if (std::fabs(*magnitude) > 0.0) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-BOUNDARY-NONZERO", "Nonzero prescribed displacement is not supported in Phase 1", {file_name, line, "boundary"}}); - return; - } - result.domain.boundary_conditions.push_back({trim(fields[0]), static_cast(*first), static_cast(*last), *magnitude}); - } - - static void parseLoad(const std::vector& fields, ParseResult& result, const std::string& file_name, LocalIndex line) { - const std::size_t field_count = effectiveFieldCount(fields); - if (field_count < 3) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-CLOAD", "*Cload requires target,dof,magnitude", {file_name, line, "cload"}}); - return; - } - if (field_count > 3) { - result.diagnostics.push_back({Severity::Error, - "FESA-PARSE-CLOAD-UNSUPPORTED", - "Only direct concentrated load data is supported", - {file_name, line, "cload"}}); - return; - } - auto dof = parseInt64(fields[1]); - auto magnitude = parseReal(fields[2]); - if (!dof || !magnitude || !dofFromAbaqus(static_cast(*dof))) { - result.diagnostics.push_back({Severity::Error, "FESA-PARSE-CLOAD-DOF", "Invalid concentrated load", {file_name, line, "cload"}}); - return; - } - result.domain.loads.push_back({trim(fields[0]), static_cast(*dof), *magnitude}); - } -}; - inline SparsePattern buildReducedSparsePattern(const Domain& domain, const DofManager& dofs) { SparsePattern pattern; pattern.equation_count = dofs.freeDofCount(); diff --git a/phases/1-structure-alignment-refactor/index.json b/phases/1-structure-alignment-refactor/index.json index 4632d30..d1df354 100644 --- a/phases/1-structure-alignment-refactor/index.json +++ b/phases/1-structure-alignment-refactor/index.json @@ -6,7 +6,7 @@ { "step": 1, "name": "module-scaffold-and-facade", "status": "completed" }, { "step": 2, "name": "core-domain-dof-extraction", "status": "completed" }, { "step": 3, "name": "math-solver-extraction", "status": "completed" }, - { "step": 4, "name": "io-parser-extraction", "status": "pending" }, + { "step": 4, "name": "io-parser-extraction", "status": "completed" }, { "step": 5, "name": "results-reference-extraction", "status": "pending" }, { "step": 6, "name": "mitc4-geometry-strain-extraction", "status": "pending" }, { "step": 7, "name": "mitc4-material-stiffness-extraction", "status": "pending" }, diff --git a/tests/test_io_module_includes.cpp b/tests/test_io_module_includes.cpp new file mode 100644 index 0000000..1749526 --- /dev/null +++ b/tests/test_io_module_includes.cpp @@ -0,0 +1,64 @@ +#include "fesa/IO/IO.hpp" + +#include +#include +#include +#include + +namespace { + +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +std::string sourceRoot() { +#ifdef FESA_SOURCE_DIR + return FESA_SOURCE_DIR; +#else + return "."; +#endif +} + +std::string readTextFile(const std::string& path) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("failed to open " + path); + } + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +} // namespace + +int main() { + const auto continued = fesa::parseKeywordLine("*Element, type=S4,"); + check(continued.name == "element", "Keyword name normalization changed"); + check(continued.parameters.at("type") == "S4", "Keyword parameter parsing changed"); + + const fesa::AbaqusInputParser parser; + + const auto normalized = parser.parseFile(sourceRoot() + "/references/quad_02_phase1.inp"); + check(normalized.ok(), "quad_02_phase1 normalized input should remain accepted"); + check(normalized.domain.nodes.size() == 121, "quad_02_phase1 node count changed"); + check(normalized.domain.elements.size() == 100, "quad_02_phase1 element count changed"); + check(normalized.domain.node_sets.at("fixed_boundary").node_ids.size() == 40, "quad_02_phase1 fixed set changed"); + check(normalized.domain.node_sets.at("load_node").node_ids.size() == 1, "quad_02_phase1 load set changed"); + check(normalized.domain.element_sets.at("all_elements").element_ids.size() == 100, "quad_02_phase1 element set changed"); + check(normalized.domain.materials.at("material_1").elastic_modulus == 7.0e10, "quad_02_phase1 material changed"); + check(normalized.domain.shell_sections.front().thickness == 1.0, "quad_02_phase1 shell section changed"); + + const auto original = parser.parseFile(sourceRoot() + "/references/quad_02.inp"); + check(!original.ok(), "original quad_02.inp should remain unsupported provenance"); + check(fesa::containsDiagnostic(original.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD"), + "original quad_02.inp unsupported keyword diagnostic changed"); + + const auto nonzero_bc = parser.parseString("*Node\n1, 0, 0, 0\n*Boundary\n1, 1, 1, 0.5\n", "nonzero_bc.inp"); + check(!nonzero_bc.ok(), "nonzero prescribed displacement should remain unsupported"); + check(fesa::containsDiagnostic(nonzero_bc.diagnostics, "FESA-PARSE-BOUNDARY-NONZERO"), + "nonzero prescribed displacement diagnostic changed"); + + return 0; +}