diff --git a/CMakeLists.txt b/CMakeLists.txt index 6638490..44d0ea3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,12 +18,18 @@ add_executable(fesa_tests tests/test_main.cpp) target_link_libraries(fesa_tests PRIVATE fesa_core) target_compile_definitions(fesa_tests PRIVATE FESA_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") +add_executable(fesa_core_module_tests tests/test_core_module_includes.cpp) +target_link_libraries(fesa_core_module_tests PRIVATE fesa_core) + 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-) 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) endif() add_test(NAME fesa_tests COMMAND fesa_tests) +add_test(NAME fesa_core_module_tests COMMAND fesa_core_module_tests) diff --git a/PLAN.md b/PLAN.md index e47687a..49e7c02 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-02 Core/Util extraction. P1A-00 completed the architecture drift audit and P1A-01 created the module scaffold, source directories, CMake source boundary, and include smoke test without moving production 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-03 Math and solver adapter extraction. P1A-00 completed the architecture drift audit, P1A-01 created the module scaffold, and P1A-02 extracted Core/Util plus Phase 1 Boundary/Load/Property model ownership 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` extracts Core/Util domain, diagnostics, DofManager ownership, and Phase 1 Boundary/Load/Property model ownership; `step3.md` extracts Math and solver adapter boundaries; `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` extracts Math and solver adapter boundaries; `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. - 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`. @@ -59,7 +59,7 @@ This phase is an architecture-preserving refactor. It must not change Phase 1 so |---|---|---|---|---|---| | P1A-00 | completed | planner/evaluator | Audit `fesa.hpp` architecture drift and create a symbol-to-module migration map. | P1R-15 | Complete migration map and validation baseline | | 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 | pending | 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-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 | pending | 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-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 | diff --git a/PROGRESS.md b/PROGRESS.md index a2139d5..4cbc594 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,10 +13,52 @@ 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 and P1A-01 are complete, so the next step is P1A-02 Core/Util 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, P1A-01, and P1A-02 are complete, so the next step is P1A-03 Math and solver adapter 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-02 Core domain DOF extraction completed +Author: Codex + +Changed files: +- `CMakeLists.txt` +- `include/fesa/Boundary/Boundary.hpp` +- `include/fesa/Core/AnalysisModel.hpp` +- `include/fesa/Core/AnalysisState.hpp` +- `include/fesa/Core/Core.hpp` +- `include/fesa/Core/Dof.hpp` +- `include/fesa/Core/DofManager.hpp` +- `include/fesa/Core/Domain.hpp` +- `include/fesa/Core/Types.hpp` +- `include/fesa/Core/Validation.hpp` +- `include/fesa/Load/Load.hpp` +- `include/fesa/Property/Property.hpp` +- `include/fesa/Util/Diagnostics.hpp` +- `include/fesa/Util/String.hpp` +- `include/fesa/Util/Util.hpp` +- `include/fesa/fesa.hpp` +- `tests/test_core_module_includes.cpp` +- `phases/1-structure-alignment-refactor/index.json` +- `PLAN.md` +- `PROGRESS.md` + +Summary: +- Extracted numeric aliases, diagnostics, string/parse helpers, DOF mapping, Domain records, validation helpers, `AnalysisModel`, `AnalysisState`, and `DofManager` from the umbrella header into Core and Util module headers. +- Moved Phase 1 `BoundaryCondition`, `NodalLoad`, and `ShellSection` model records into their Boundary, Load, and Property module headers. +- Added `fesa_core_module_tests`, a direct module include smoke test that does not include `fesa/fesa.hpp` and checks aliases, Domain, diagnostics, DOF mapping, Boundary/Load/Property records, `DofManager`, `AnalysisModel`, and `AnalysisState`. +- Preserved public symbol names and namespace `fesa`; `fesa/fesa.hpp` still works as the umbrella facade. +- Kept lightweight `Material` as a Domain record under Core for now to avoid a Core-to-Material dependency cycle before IO extraction; later material-law helpers remain part of P1A-07. +- Remaining large groups in `fesa.hpp` are Math/solver helpers, IO parser, MITC4 Element helpers, Assembly, Results/reference comparison, and Analysis workflow. `Vec3` arithmetic helpers also remain there until P1A-03 extracts Math. + +Verification: +- First ran `python scripts/validate_workspace.py` after adding the direct module include test; it failed as expected because Core/Boundary/Load/Property/Util headers did not yet expose the required symbols. +- After extraction, `python scripts/validate_workspace.py` configured CMake, built `fesa_core`, `fesa_tests`, and `fesa_core_module_tests`, and ran CTest successfully. +- CTest result: 2 test executables passed. + +Follow-up: +- Continue with P1A-03 Math and solver adapter extraction. +- Keep R-014 open until P1A-09 independently accepts the final architecture alignment. + ### 2026-05-05 - P1A-01 module scaffold and facade completed Author: Codex diff --git a/include/fesa/Boundary/Boundary.hpp b/include/fesa/Boundary/Boundary.hpp index 5a79710..b87c298 100644 --- a/include/fesa/Boundary/Boundary.hpp +++ b/include/fesa/Boundary/Boundary.hpp @@ -1,9 +1,23 @@ #pragma once +#include "fesa/Core/Types.hpp" #include "fesa/ModuleInfo.hpp" +#include + namespace fesa::module { inline constexpr std::string_view kBoundary = "Boundary"; } // namespace fesa::module + +namespace fesa { + +struct BoundaryCondition { + std::string target; + int first_dof = 0; + int last_dof = 0; + Real magnitude = 0.0; +}; + +} // namespace fesa diff --git a/include/fesa/Core/AnalysisModel.hpp b/include/fesa/Core/AnalysisModel.hpp new file mode 100644 index 0000000..07e142e --- /dev/null +++ b/include/fesa/Core/AnalysisModel.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "fesa/Core/Domain.hpp" +#include "fesa/Util/Diagnostics.hpp" + +#include +#include +#include + +namespace fesa { + +struct AnalysisModel { + StepDefinition step; + std::vector active_element_ids; + std::vector active_boundary_condition_indices; + std::vector active_load_indices; + std::vector active_shell_section_indices; + std::vector active_material_keys; + std::vector diagnostics; + + bool ok() const { + return !hasError(diagnostics); + } +}; + +inline AnalysisModel buildLinearStaticAnalysisModel(const Domain& domain, LocalIndex step_index = 0) { + AnalysisModel model; + if (domain.steps.empty()) { + model.step = {"Step-1", "linear_static"}; + } else { + if (step_index < 0 || step_index >= static_cast(domain.steps.size())) { + model.diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-ANALYSIS-STEP-INDEX", + "Requested analysis step index is out of range", "analysis model")); + model.step = domain.steps.front(); + } else { + model.step = domain.steps[static_cast(step_index)]; + } + } + if (domain.steps.size() > 1) { + model.diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-ANALYSIS-MULTIPLE-STEPS", + "Phase 1 execution supports one active linear static step", "analysis model")); + } + if (model.step.analysis_type != "linear_static") { + model.diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-ANALYSIS-UNSUPPORTED-STEP", + "Only linear static steps are supported in Phase 1", "analysis model")); + } + for (const auto& [element_id, element] : domain.elements) { + (void)element; + model.active_element_ids.push_back(element_id); + } + for (std::size_t i = 0; i < domain.boundary_conditions.size(); ++i) { + model.active_boundary_condition_indices.push_back(i); + } + for (std::size_t i = 0; i < domain.loads.size(); ++i) { + model.active_load_indices.push_back(i); + } + for (std::size_t i = 0; i < domain.shell_sections.size(); ++i) { + model.active_shell_section_indices.push_back(i); + } + for (const auto& [material_key, material] : domain.materials) { + (void)material; + model.active_material_keys.push_back(material_key); + } + return model; +} + +} // namespace fesa diff --git a/include/fesa/Core/AnalysisState.hpp b/include/fesa/Core/AnalysisState.hpp new file mode 100644 index 0000000..4a577dd --- /dev/null +++ b/include/fesa/Core/AnalysisState.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "fesa/Core/Types.hpp" + +#include + +namespace fesa { + +struct AnalysisState { + std::vector u_full; + std::vector f_external_full; + std::vector f_internal_full; + std::vector reaction_full; + bool converged = false; +}; + +} // namespace fesa diff --git a/include/fesa/Core/Core.hpp b/include/fesa/Core/Core.hpp index 9a16d9e..5b00cef 100644 --- a/include/fesa/Core/Core.hpp +++ b/include/fesa/Core/Core.hpp @@ -1,5 +1,12 @@ #pragma once +#include "fesa/Core/AnalysisModel.hpp" +#include "fesa/Core/AnalysisState.hpp" +#include "fesa/Core/Dof.hpp" +#include "fesa/Core/DofManager.hpp" +#include "fesa/Core/Domain.hpp" +#include "fesa/Core/Types.hpp" +#include "fesa/Core/Validation.hpp" #include "fesa/ModuleInfo.hpp" namespace fesa::module { diff --git a/include/fesa/Core/Dof.hpp b/include/fesa/Core/Dof.hpp new file mode 100644 index 0000000..d7aac31 --- /dev/null +++ b/include/fesa/Core/Dof.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "fesa/Core/Types.hpp" + +#include +#include +#include +#include + +namespace fesa { + +enum class Dof : int { UX = 0, UY = 1, UZ = 2, RX = 3, RY = 4, RZ = 5 }; + +inline std::array allDofs() { + return {Dof::UX, Dof::UY, Dof::UZ, Dof::RX, Dof::RY, Dof::RZ}; +} + +inline int dofIndex(Dof dof) { + return static_cast(dof); +} + +inline int abaqusDofNumber(Dof dof) { + return dofIndex(dof) + 1; +} + +inline std::optional dofFromAbaqus(int dof) { + if (dof < 1 || dof > 6) { + return std::nullopt; + } + return static_cast(dof - 1); +} + +inline const char* dofLabel(Dof dof) { + switch (dof) { + case Dof::UX: + return "UX"; + case Dof::UY: + return "UY"; + case Dof::UZ: + return "UZ"; + case Dof::RX: + return "RX"; + case Dof::RY: + return "RY"; + case Dof::RZ: + return "RZ"; + } + return ""; +} + +inline std::vector displacementComponentLabels() { + return {"UX", "UY", "UZ", "RX", "RY", "RZ"}; +} + +inline std::vector reactionComponentLabels() { + return {"RFX", "RFY", "RFZ", "RMX", "RMY", "RMZ"}; +} + +} // namespace fesa diff --git a/include/fesa/Core/DofManager.hpp b/include/fesa/Core/DofManager.hpp new file mode 100644 index 0000000..5f7acb5 --- /dev/null +++ b/include/fesa/Core/DofManager.hpp @@ -0,0 +1,145 @@ +#pragma once + +#include "fesa/Core/Dof.hpp" +#include "fesa/Core/Domain.hpp" +#include "fesa/Core/Validation.hpp" + +#include +#include +#include +#include +#include + +namespace fesa { + +struct DofAddress { + GlobalId node_id = 0; + Dof dof = Dof::UX; +}; + +class DofManager { + public: + explicit DofManager(const Domain& domain) { + for (const auto& [node_id, node] : domain.nodes) { + (void)node; + node_ids_.push_back(node_id); + for (Dof dof : allDofs()) { + const LocalIndex full_index = static_cast(all_dofs_.size()); + const auto key = std::make_pair(node_id, dofIndex(dof)); + all_dofs_.push_back(key); + full_index_by_key_[key] = full_index; + } + } + for (const BoundaryCondition& boundary : domain.boundary_conditions) { + if (!validAbaqusDofRange(boundary.first_dof, boundary.last_dof)) { + continue; + } + for (GlobalId node_id : resolveNodeTarget(domain, boundary.target)) { + for (int dof = boundary.first_dof; dof <= boundary.last_dof; ++dof) { + constrained_.insert(std::make_pair(node_id, dof - 1)); + } + } + } + for (const auto& key : all_dofs_) { + const LocalIndex full_index = full_index_by_key_.at(key); + if (constrained_.count(key) == 0) { + equation_by_key_[key] = static_cast(free_full_indices_.size()); + free_full_indices_.push_back(full_index); + } else { + equation_by_key_[key] = -1; + constrained_full_indices_.push_back(full_index); + } + } + } + + LocalIndex fullDofCount() const { + return static_cast(all_dofs_.size()); + } + + LocalIndex freeDofCount() const { + return static_cast(free_full_indices_.size()); + } + + LocalIndex constrainedDofCount() const { + return static_cast(constrained_full_indices_.size()); + } + + const std::vector& nodeIds() const { + return node_ids_; + } + + const std::vector& freeFullIndices() const { + return free_full_indices_; + } + + const std::vector& constrainedFullIndices() const { + return constrained_full_indices_; + } + + DofAddress fullDof(LocalIndex full_index) const { + const auto& key = all_dofs_.at(static_cast(full_index)); + return {key.first, static_cast(key.second)}; + } + + LocalIndex fullIndex(GlobalId node_id, Dof dof) const { + return full_index_by_key_.at(std::make_pair(node_id, dofIndex(dof))); + } + + EquationId equation(GlobalId node_id, Dof dof) const { + return equation_by_key_.at(std::make_pair(node_id, dofIndex(dof))); + } + + bool isConstrained(GlobalId node_id, Dof dof) const { + return constrained_.count(std::make_pair(node_id, dofIndex(dof))) != 0; + } + + std::vector reduceFullVector(const std::vector& full) const { + std::vector reduced; + reduced.reserve(free_full_indices_.size()); + for (LocalIndex full_index : free_full_indices_) { + reduced.push_back(full.at(static_cast(full_index))); + } + return reduced; + } + + std::vector reconstructFullVector(const std::vector& reduced) const { + std::vector full(static_cast(fullDofCount()), 0.0); + for (std::size_t i = 0; i < free_full_indices_.size(); ++i) { + full[static_cast(free_full_indices_[i])] = reduced.at(i); + } + return full; + } + + std::array elementFullDofIndices(const Element& element) const { + std::array indices{}; + for (LocalIndex node = 0; node < 4; ++node) { + for (Dof dof : allDofs()) { + const LocalIndex local = 6 * node + dofIndex(dof); + indices[static_cast(local)] = fullIndex(element.node_ids[static_cast(node)], dof); + } + } + return indices; + } + + std::array elementEquationIds(const Element& element) const { + std::array equations{}; + for (LocalIndex node = 0; node < 4; ++node) { + for (Dof dof : allDofs()) { + const LocalIndex local = 6 * node + dofIndex(dof); + equations[static_cast(local)] = equation(element.node_ids[static_cast(node)], dof); + } + } + return equations; + } + + private: + std::vector node_ids_; + std::vector> all_dofs_; + std::set> constrained_; + std::map, LocalIndex> full_index_by_key_; + std::map, EquationId> equation_by_key_; + std::vector free_full_indices_; + std::vector constrained_full_indices_; +}; + +} // namespace fesa diff --git a/include/fesa/Core/Domain.hpp b/include/fesa/Core/Domain.hpp new file mode 100644 index 0000000..af1f17b --- /dev/null +++ b/include/fesa/Core/Domain.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "fesa/Boundary/Boundary.hpp" +#include "fesa/Core/Types.hpp" +#include "fesa/Load/Load.hpp" +#include "fesa/Property/Property.hpp" +#include "fesa/Util/String.hpp" + +#include +#include +#include +#include + +namespace fesa { + +struct Vec3 { + Real x = 0.0; + Real y = 0.0; + Real z = 0.0; +}; + +struct Node { + GlobalId id = 0; + Vec3 coordinates; +}; + +enum class ElementType { MITC4 }; + +inline std::string elementTypeLabel(ElementType type) { + switch (type) { + case ElementType::MITC4: + return "MITC4"; + } + return "UNKNOWN"; +} + +struct Element { + GlobalId id = 0; + ElementType type = ElementType::MITC4; + std::array node_ids{}; + std::string source_elset; +}; + +struct NodeSet { + std::string name; + std::vector node_ids; +}; + +struct ElementSet { + std::string name; + std::vector element_ids; +}; + +struct Material { + std::string name; + Real elastic_modulus = 0.0; + Real poisson_ratio = 0.0; +}; + +struct StepDefinition { + std::string name = "Step-1"; + std::string analysis_type = "linear_static"; +}; + +struct Domain { + std::map nodes; + std::map elements; + std::map node_sets; + std::map element_sets; + std::map materials; + std::vector shell_sections; + std::vector boundary_conditions; + std::vector loads; + std::vector steps; + + static std::string key(const std::string& label) { + return lower(trim(label)); + } +}; + +} // namespace fesa diff --git a/include/fesa/Core/Types.hpp b/include/fesa/Core/Types.hpp new file mode 100644 index 0000000..67bcae9 --- /dev/null +++ b/include/fesa/Core/Types.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace fesa { + +using Real = double; +using GlobalId = std::int64_t; +using LocalIndex = std::int64_t; +using EquationId = std::int64_t; +using SparseIndex = std::int64_t; + +} // namespace fesa diff --git a/include/fesa/Core/Validation.hpp b/include/fesa/Core/Validation.hpp new file mode 100644 index 0000000..f8d8f78 --- /dev/null +++ b/include/fesa/Core/Validation.hpp @@ -0,0 +1,219 @@ +#pragma once + +#include "fesa/Core/Dof.hpp" +#include "fesa/Core/Domain.hpp" +#include "fesa/Util/Diagnostics.hpp" +#include "fesa/Util/String.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { + +inline std::optional numericTarget(const std::string& target) { + return parseInt64(target); +} + +inline std::vector resolveNodeTarget(const Domain& domain, const std::string& target, std::vector* diagnostics = nullptr, + const std::string& diagnostic_keyword = "node target") { + if (auto node_id = numericTarget(target)) { + if (domain.nodes.count(*node_id) == 0) { + if (diagnostics != nullptr) { + diagnostics->push_back( + makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-NODE", "Missing node target: " + target, diagnostic_keyword)); + } + return {}; + } + return {*node_id}; + } + auto set_it = domain.node_sets.find(Domain::key(target)); + if (set_it == domain.node_sets.end()) { + if (diagnostics != nullptr) { + diagnostics->push_back( + makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-NSET", "Missing node set: " + target, diagnostic_keyword)); + } + return {}; + } + return set_it->second.node_ids; +} + +inline const ShellSection* shellSectionForElement(const Domain& domain, GlobalId element_id) { + for (const ShellSection& section : domain.shell_sections) { + auto set_it = domain.element_sets.find(Domain::key(section.element_set)); + if (set_it == domain.element_sets.end()) { + continue; + } + if (std::find(set_it->second.element_ids.begin(), set_it->second.element_ids.end(), element_id) != set_it->second.element_ids.end()) { + return §ion; + } + } + return nullptr; +} + +inline std::string dofNameOrNumber(int abaqus_dof) { + auto dof = dofFromAbaqus(abaqus_dof); + if (dof) { + return dofLabel(*dof); + } + return "DOF " + std::to_string(abaqus_dof); +} + +inline bool validAbaqusDofRange(int first, int last) { + return dofFromAbaqus(first).has_value() && dofFromAbaqus(last).has_value() && first <= last; +} + +inline std::vector validateDomain(const Domain& domain) { + std::vector diagnostics; + if (domain.elements.empty()) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-SINGULAR-NO-ACTIVE-ELEMENTS", + "No active elements exist in the current model", "analysis model")); + } + if (domain.boundary_conditions.empty()) { + diagnostics.push_back(makeDiagnostic(Severity::Warning, "FESA-SINGULAR-NO-BOUNDARY", "No boundary constraints are defined", "boundary")); + } + for (const auto& [set_key, set] : domain.node_sets) { + (void)set_key; + for (GlobalId node_id : set.node_ids) { + if (domain.nodes.count(node_id) == 0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-NSET-MISSING-NODE", + "Node set " + set.name + " references missing node " + std::to_string(node_id), + "nset")); + } + } + } + for (const auto& [set_key, set] : domain.element_sets) { + (void)set_key; + for (GlobalId element_id : set.element_ids) { + if (domain.elements.count(element_id) == 0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-ELSET-MISSING-ELEMENT", + "Element set " + set.name + " references missing element " + std::to_string(element_id), + "elset")); + } + } + } + for (const auto& [id, element] : domain.elements) { + for (GlobalId node_id : element.node_ids) { + if (domain.nodes.count(node_id) == 0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-ELEMENT-MISSING-NODE", + "Element " + std::to_string(id) + " references missing node " + std::to_string(node_id), + "element")); + } + } + const ShellSection* section = shellSectionForElement(domain, id); + if (section == nullptr) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-PROPERTY", + "Element " + std::to_string(id) + " has no assigned shell section", "element")); + } + } + for (const ShellSection& section : domain.shell_sections) { + if (section.thickness <= 0.0 || !std::isfinite(section.thickness)) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-NONPOSITIVE-THICKNESS", + "Shell section for element set " + section.element_set + " has non-positive thickness", + "shell section")); + } + if (domain.element_sets.count(Domain::key(section.element_set)) == 0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-ELSET", + "Shell section references missing element set: " + section.element_set, "shell section")); + } + auto material_it = domain.materials.find(Domain::key(section.material)); + if (material_it == domain.materials.end()) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-MATERIAL", + "Shell section references missing material: " + section.material, "shell section")); + } else if (material_it->second.elastic_modulus <= 0.0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-INCOMPLETE-MATERIAL", + "Material has no valid elastic constants: " + section.material, "material")); + } + } + for (const BoundaryCondition& boundary : domain.boundary_conditions) { + if (!validAbaqusDofRange(boundary.first_dof, boundary.last_dof)) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-BOUNDARY-DOF", + "Boundary target " + boundary.target + " has invalid DOF range " + + dofNameOrNumber(boundary.first_dof) + " to " + dofNameOrNumber(boundary.last_dof), + "boundary")); + } + (void)resolveNodeTarget(domain, boundary.target, &diagnostics, "boundary"); + } + for (const NodalLoad& load : domain.loads) { + if (!dofFromAbaqus(load.dof)) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-CLOAD-DOF", + "Load target " + load.target + " has invalid " + dofNameOrNumber(load.dof), "cload")); + } + (void)resolveNodeTarget(domain, load.target, &diagnostics, "cload"); + } + const bool any_nonzero_load = std::any_of(domain.loads.begin(), domain.loads.end(), [](const NodalLoad& load) { + return std::fabs(load.magnitude) > 0.0; + }); + if (!any_nonzero_load) { + diagnostics.push_back(makeDiagnostic(Severity::Warning, "FESA-SINGULAR-NO-NONZERO-LOAD", "No nonzero load is defined", "cload")); + } + + std::set> constrained_dofs; + for (const BoundaryCondition& boundary : domain.boundary_conditions) { + if (!validAbaqusDofRange(boundary.first_dof, boundary.last_dof)) { + continue; + } + for (GlobalId node_id : resolveNodeTarget(domain, boundary.target)) { + if (domain.nodes.count(node_id) == 0) { + continue; + } + for (int dof = boundary.first_dof; dof <= boundary.last_dof; ++dof) { + constrained_dofs.insert(std::make_pair(node_id, dof - 1)); + } + } + } + + std::set active_connectivity_nodes; + for (const auto& [element_id, element] : domain.elements) { + (void)element_id; + for (GlobalId node_id : element.node_ids) { + if (domain.nodes.count(node_id) != 0) { + active_connectivity_nodes.insert(node_id); + } + } + } + + LocalIndex free_dof_count = 0; + LocalIndex weak_drilling_count = 0; + GlobalId weak_drilling_example = 0; + for (const auto& [node_id, node] : domain.nodes) { + (void)node; + for (Dof dof : allDofs()) { + const auto key = std::make_pair(node_id, dofIndex(dof)); + if (constrained_dofs.count(key) != 0) { + continue; + } + ++free_dof_count; + if (!domain.elements.empty() && active_connectivity_nodes.count(node_id) == 0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-SINGULAR-DOF-UNTOUCHED", + "Node " + std::to_string(node_id) + " DOF " + dofLabel(dof) + + " is free but is not touched by active element connectivity", + "dof")); + } + if (!domain.elements.empty() && active_connectivity_nodes.count(node_id) != 0 && dof == Dof::RZ) { + if (weak_drilling_count == 0) { + weak_drilling_example = node_id; + } + ++weak_drilling_count; + } + } + } + if (!domain.nodes.empty() && free_dof_count == 0) { + diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-SINGULAR-NO-FREE-DOFS", + "No free DOFs exist after applying boundary constraints", "dof")); + } + if (weak_drilling_count > 0) { + diagnostics.push_back(makeDiagnostic(Severity::Warning, "FESA-SINGULAR-WEAK-DRILLING-DOF", + "Node " + std::to_string(weak_drilling_example) + + " DOF RZ is free; drilling rotation is weakly stabilized in Phase 1 (" + + std::to_string(weak_drilling_count) + " free drilling DOF(s))", + "dof")); + } + return diagnostics; +} + +} // namespace fesa diff --git a/include/fesa/Load/Load.hpp b/include/fesa/Load/Load.hpp index e053c2f..6e4ef67 100644 --- a/include/fesa/Load/Load.hpp +++ b/include/fesa/Load/Load.hpp @@ -1,9 +1,22 @@ #pragma once +#include "fesa/Core/Types.hpp" #include "fesa/ModuleInfo.hpp" +#include + namespace fesa::module { inline constexpr std::string_view kLoad = "Load"; } // namespace fesa::module + +namespace fesa { + +struct NodalLoad { + std::string target; + int dof = 0; + Real magnitude = 0.0; +}; + +} // namespace fesa diff --git a/include/fesa/Property/Property.hpp b/include/fesa/Property/Property.hpp index c5507ee..708d42b 100644 --- a/include/fesa/Property/Property.hpp +++ b/include/fesa/Property/Property.hpp @@ -1,9 +1,22 @@ #pragma once +#include "fesa/Core/Types.hpp" #include "fesa/ModuleInfo.hpp" +#include + namespace fesa::module { inline constexpr std::string_view kProperty = "Property"; } // namespace fesa::module + +namespace fesa { + +struct ShellSection { + std::string element_set; + std::string material; + Real thickness = 0.0; +}; + +} // namespace fesa diff --git a/include/fesa/Util/Diagnostics.hpp b/include/fesa/Util/Diagnostics.hpp new file mode 100644 index 0000000..0aa8ff3 --- /dev/null +++ b/include/fesa/Util/Diagnostics.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "fesa/Core/Types.hpp" + +#include +#include +#include +#include + +namespace fesa { + +enum class Severity { Info, Warning, Error }; + +struct SourceLocation { + std::string file; + LocalIndex line = 0; + std::string keyword; +}; + +struct Diagnostic { + Severity severity = Severity::Error; + std::string code; + std::string message; + SourceLocation source; +}; + +inline bool hasError(const std::vector& diagnostics) { + return std::any_of(diagnostics.begin(), diagnostics.end(), [](const Diagnostic& diagnostic) { + return diagnostic.severity == Severity::Error; + }); +} + +inline bool containsDiagnostic(const std::vector& diagnostics, const std::string& code) { + return std::any_of(diagnostics.begin(), diagnostics.end(), [&](const Diagnostic& diagnostic) { + return diagnostic.code == code; + }); +} + +inline Diagnostic makeDiagnostic(Severity severity, std::string code, std::string message, std::string keyword, + std::string file = "", LocalIndex line = 0) { + return {severity, std::move(code), std::move(message), {std::move(file), line, std::move(keyword)}}; +} + +} // namespace fesa diff --git a/include/fesa/Util/String.hpp b/include/fesa/Util/String.hpp new file mode 100644 index 0000000..7d42783 --- /dev/null +++ b/include/fesa/Util/String.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include "fesa/Core/Types.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fesa { + +inline std::string trim(std::string text) { + auto is_space = [](unsigned char c) { return std::isspace(c) != 0; }; + text.erase(text.begin(), std::find_if(text.begin(), text.end(), [&](unsigned char c) { return !is_space(c); })); + text.erase(std::find_if(text.rbegin(), text.rend(), [&](unsigned char c) { return !is_space(c); }).base(), text.end()); + return text; +} + +inline std::string lower(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return text; +} + +inline std::vector splitCsv(const std::string& line) { + std::vector fields; + std::string field; + std::istringstream stream(line); + while (std::getline(stream, field, ',')) { + fields.push_back(trim(field)); + } + if (!line.empty() && line.back() == ',') { + fields.emplace_back(); + } + return fields; +} + +inline std::optional parseReal(std::string token) { + token = trim(token); + if (token.empty()) { + return std::nullopt; + } + std::replace(token.begin(), token.end(), 'D', 'E'); + std::replace(token.begin(), token.end(), 'd', 'e'); + try { + std::size_t used = 0; + Real value = std::stod(token, &used); + if (used != token.size()) { + return std::nullopt; + } + return value; + } catch (...) { + return std::nullopt; + } +} + +inline std::optional parseInt64(const std::string& token) { + std::string value_text = trim(token); + if (value_text.empty()) { + return std::nullopt; + } + try { + std::size_t used = 0; + long long value = std::stoll(value_text, &used); + if (used != value_text.size()) { + return std::nullopt; + } + return static_cast(value); + } catch (...) { + return std::nullopt; + } +} + +inline void addUnique(std::vector& values, GlobalId value) { + if (std::find(values.begin(), values.end(), value) == values.end()) { + values.push_back(value); + } +} + +inline std::vector generatedRange(GlobalId first, GlobalId last, GlobalId increment) { + std::vector values; + if (increment <= 0) { + return values; + } + for (GlobalId value = first; value <= last; value += increment) { + values.push_back(value); + } + return values; +} + +} // namespace fesa diff --git a/include/fesa/Util/Util.hpp b/include/fesa/Util/Util.hpp index 4ed8e9e..cc8994e 100644 --- a/include/fesa/Util/Util.hpp +++ b/include/fesa/Util/Util.hpp @@ -1,5 +1,7 @@ #pragma once +#include "fesa/Util/Diagnostics.hpp" +#include "fesa/Util/String.hpp" #include "fesa/ModuleInfo.hpp" namespace fesa::module { diff --git a/include/fesa/fesa.hpp b/include/fesa/fesa.hpp index 10ee56c..6dff244 100644 --- a/include/fesa/fesa.hpp +++ b/include/fesa/fesa.hpp @@ -1,6 +1,11 @@ #pragma once +#include "fesa/Boundary/Boundary.hpp" +#include "fesa/Core/Core.hpp" +#include "fesa/Load/Load.hpp" #include "fesa/ModuleInfo.hpp" +#include "fesa/Property/Property.hpp" +#include "fesa/Util/Util.hpp" #include #include @@ -22,160 +27,6 @@ namespace fesa { -using Real = double; -using GlobalId = std::int64_t; -using LocalIndex = std::int64_t; -using EquationId = std::int64_t; -using SparseIndex = std::int64_t; - -enum class Severity { Info, Warning, Error }; - -struct SourceLocation { - std::string file; - LocalIndex line = 0; - std::string keyword; -}; - -struct Diagnostic { - Severity severity = Severity::Error; - std::string code; - std::string message; - SourceLocation source; -}; - -inline bool hasError(const std::vector& diagnostics) { - return std::any_of(diagnostics.begin(), diagnostics.end(), [](const Diagnostic& diagnostic) { - return diagnostic.severity == Severity::Error; - }); -} - -inline bool containsDiagnostic(const std::vector& diagnostics, const std::string& code) { - return std::any_of(diagnostics.begin(), diagnostics.end(), [&](const Diagnostic& diagnostic) { - return diagnostic.code == code; - }); -} - -inline Diagnostic makeDiagnostic(Severity severity, std::string code, std::string message, std::string keyword, - std::string file = "", LocalIndex line = 0) { - return {severity, std::move(code), std::move(message), {std::move(file), line, std::move(keyword)}}; -} - -inline std::string trim(std::string text) { - auto is_space = [](unsigned char c) { return std::isspace(c) != 0; }; - text.erase(text.begin(), std::find_if(text.begin(), text.end(), [&](unsigned char c) { return !is_space(c); })); - text.erase(std::find_if(text.rbegin(), text.rend(), [&](unsigned char c) { return !is_space(c); }).base(), text.end()); - return text; -} - -inline std::string lower(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return text; -} - -inline std::vector splitCsv(const std::string& line) { - std::vector fields; - std::string field; - std::istringstream stream(line); - while (std::getline(stream, field, ',')) { - fields.push_back(trim(field)); - } - if (!line.empty() && line.back() == ',') { - fields.emplace_back(); - } - return fields; -} - -inline std::optional parseReal(std::string token) { - token = trim(token); - if (token.empty()) { - return std::nullopt; - } - std::replace(token.begin(), token.end(), 'D', 'E'); - std::replace(token.begin(), token.end(), 'd', 'e'); - try { - std::size_t used = 0; - Real value = std::stod(token, &used); - if (used != token.size()) { - return std::nullopt; - } - return value; - } catch (...) { - return std::nullopt; - } -} - -inline std::optional parseInt64(const std::string& token) { - std::string value_text = trim(token); - if (value_text.empty()) { - return std::nullopt; - } - try { - std::size_t used = 0; - long long value = std::stoll(value_text, &used); - if (used != value_text.size()) { - return std::nullopt; - } - return static_cast(value); - } catch (...) { - return std::nullopt; - } -} - -enum class Dof : int { UX = 0, UY = 1, UZ = 2, RX = 3, RY = 4, RZ = 5 }; - -inline std::array allDofs() { - return {Dof::UX, Dof::UY, Dof::UZ, Dof::RX, Dof::RY, Dof::RZ}; -} - -inline int dofIndex(Dof dof) { - return static_cast(dof); -} - -inline int abaqusDofNumber(Dof dof) { - return dofIndex(dof) + 1; -} - -inline std::optional dofFromAbaqus(int dof) { - if (dof < 1 || dof > 6) { - return std::nullopt; - } - return static_cast(dof - 1); -} - -inline const char* dofLabel(Dof dof) { - switch (dof) { - case Dof::UX: - return "UX"; - case Dof::UY: - return "UY"; - case Dof::UZ: - return "UZ"; - case Dof::RX: - return "RX"; - case Dof::RY: - return "RY"; - case Dof::RZ: - return "RZ"; - } - return ""; -} - -inline std::vector displacementComponentLabels() { - return {"UX", "UY", "UZ", "RX", "RY", "RZ"}; -} - -inline std::vector reactionComponentLabels() { - return {"RFX", "RFY", "RFZ", "RMX", "RMY", "RMZ"}; -} - -struct Vec3 { - Real x = 0.0; - Real y = 0.0; - Real z = 0.0; -}; - inline Vec3 operator+(const Vec3& a, const Vec3& b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } @@ -224,101 +75,6 @@ inline Vec3 normalized(const Vec3& value) { return (1.0 / length) * value; } -struct Node { - GlobalId id = 0; - Vec3 coordinates; -}; - -enum class ElementType { MITC4 }; - -inline std::string elementTypeLabel(ElementType type) { - switch (type) { - case ElementType::MITC4: - return "MITC4"; - } - return "UNKNOWN"; -} - -struct Element { - GlobalId id = 0; - ElementType type = ElementType::MITC4; - std::array node_ids{}; - std::string source_elset; -}; - -struct NodeSet { - std::string name; - std::vector node_ids; -}; - -struct ElementSet { - std::string name; - std::vector element_ids; -}; - -struct Material { - std::string name; - Real elastic_modulus = 0.0; - Real poisson_ratio = 0.0; -}; - -struct ShellSection { - std::string element_set; - std::string material; - Real thickness = 0.0; -}; - -struct BoundaryCondition { - std::string target; - int first_dof = 0; - int last_dof = 0; - Real magnitude = 0.0; -}; - -struct NodalLoad { - std::string target; - int dof = 0; - Real magnitude = 0.0; -}; - -struct StepDefinition { - std::string name = "Step-1"; - std::string analysis_type = "linear_static"; -}; - -struct Domain { - std::map nodes; - std::map elements; - std::map node_sets; - std::map element_sets; - std::map materials; - std::vector shell_sections; - std::vector boundary_conditions; - std::vector loads; - std::vector steps; - - static std::string key(const std::string& label) { - return lower(trim(label)); - } -}; - -inline void addUnique(std::vector& values, GlobalId value) { - if (std::find(values.begin(), values.end(), value) == values.end()) { - values.push_back(value); - } -} - -inline std::vector generatedRange(GlobalId first, GlobalId last, GlobalId increment) { - std::vector values; - if (increment <= 0) { - return values; - } - for (GlobalId value = first; value <= last; value += increment) { - values.push_back(value); - } - return values; -} - struct KeywordLine { std::string name; std::map parameters; @@ -762,392 +518,6 @@ class AbaqusInputParser { } }; -inline std::optional numericTarget(const std::string& target) { - return parseInt64(target); -} - -inline std::vector resolveNodeTarget(const Domain& domain, const std::string& target, std::vector* diagnostics = nullptr, - const std::string& diagnostic_keyword = "node target") { - if (auto node_id = numericTarget(target)) { - if (domain.nodes.count(*node_id) == 0) { - if (diagnostics != nullptr) { - diagnostics->push_back( - makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-NODE", "Missing node target: " + target, diagnostic_keyword)); - } - return {}; - } - return {*node_id}; - } - auto set_it = domain.node_sets.find(Domain::key(target)); - if (set_it == domain.node_sets.end()) { - if (diagnostics != nullptr) { - diagnostics->push_back( - makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-NSET", "Missing node set: " + target, diagnostic_keyword)); - } - return {}; - } - return set_it->second.node_ids; -} - -inline const ShellSection* shellSectionForElement(const Domain& domain, GlobalId element_id) { - for (const ShellSection& section : domain.shell_sections) { - auto set_it = domain.element_sets.find(Domain::key(section.element_set)); - if (set_it == domain.element_sets.end()) { - continue; - } - if (std::find(set_it->second.element_ids.begin(), set_it->second.element_ids.end(), element_id) != set_it->second.element_ids.end()) { - return §ion; - } - } - return nullptr; -} - -inline std::string dofNameOrNumber(int abaqus_dof) { - auto dof = dofFromAbaqus(abaqus_dof); - if (dof) { - return dofLabel(*dof); - } - return "DOF " + std::to_string(abaqus_dof); -} - -inline bool validAbaqusDofRange(int first, int last) { - return dofFromAbaqus(first).has_value() && dofFromAbaqus(last).has_value() && first <= last; -} - -inline std::vector validateDomain(const Domain& domain) { - std::vector diagnostics; - if (domain.elements.empty()) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-SINGULAR-NO-ACTIVE-ELEMENTS", - "No active elements exist in the current model", "analysis model")); - } - if (domain.boundary_conditions.empty()) { - diagnostics.push_back(makeDiagnostic(Severity::Warning, "FESA-SINGULAR-NO-BOUNDARY", "No boundary constraints are defined", "boundary")); - } - for (const auto& [set_key, set] : domain.node_sets) { - (void)set_key; - for (GlobalId node_id : set.node_ids) { - if (domain.nodes.count(node_id) == 0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-NSET-MISSING-NODE", - "Node set " + set.name + " references missing node " + std::to_string(node_id), - "nset")); - } - } - } - for (const auto& [set_key, set] : domain.element_sets) { - (void)set_key; - for (GlobalId element_id : set.element_ids) { - if (domain.elements.count(element_id) == 0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-ELSET-MISSING-ELEMENT", - "Element set " + set.name + " references missing element " + std::to_string(element_id), - "elset")); - } - } - } - for (const auto& [id, element] : domain.elements) { - for (GlobalId node_id : element.node_ids) { - if (domain.nodes.count(node_id) == 0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-ELEMENT-MISSING-NODE", - "Element " + std::to_string(id) + " references missing node " + std::to_string(node_id), - "element")); - } - } - const ShellSection* section = shellSectionForElement(domain, id); - if (section == nullptr) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-PROPERTY", - "Element " + std::to_string(id) + " has no assigned shell section", "element")); - } - } - for (const ShellSection& section : domain.shell_sections) { - if (section.thickness <= 0.0 || !std::isfinite(section.thickness)) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-NONPOSITIVE-THICKNESS", - "Shell section for element set " + section.element_set + " has non-positive thickness", - "shell section")); - } - if (domain.element_sets.count(Domain::key(section.element_set)) == 0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-ELSET", - "Shell section references missing element set: " + section.element_set, "shell section")); - } - auto material_it = domain.materials.find(Domain::key(section.material)); - if (material_it == domain.materials.end()) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-MISSING-MATERIAL", - "Shell section references missing material: " + section.material, "shell section")); - } else if (material_it->second.elastic_modulus <= 0.0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-INCOMPLETE-MATERIAL", - "Material has no valid elastic constants: " + section.material, "material")); - } - } - for (const BoundaryCondition& boundary : domain.boundary_conditions) { - if (!validAbaqusDofRange(boundary.first_dof, boundary.last_dof)) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-BOUNDARY-DOF", - "Boundary target " + boundary.target + " has invalid DOF range " + - dofNameOrNumber(boundary.first_dof) + " to " + dofNameOrNumber(boundary.last_dof), - "boundary")); - } - (void)resolveNodeTarget(domain, boundary.target, &diagnostics, "boundary"); - } - for (const NodalLoad& load : domain.loads) { - if (!dofFromAbaqus(load.dof)) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-VALIDATION-CLOAD-DOF", - "Load target " + load.target + " has invalid " + dofNameOrNumber(load.dof), "cload")); - } - (void)resolveNodeTarget(domain, load.target, &diagnostics, "cload"); - } - const bool any_nonzero_load = std::any_of(domain.loads.begin(), domain.loads.end(), [](const NodalLoad& load) { - return std::fabs(load.magnitude) > 0.0; - }); - if (!any_nonzero_load) { - diagnostics.push_back(makeDiagnostic(Severity::Warning, "FESA-SINGULAR-NO-NONZERO-LOAD", "No nonzero load is defined", "cload")); - } - - std::set> constrained_dofs; - for (const BoundaryCondition& boundary : domain.boundary_conditions) { - if (!validAbaqusDofRange(boundary.first_dof, boundary.last_dof)) { - continue; - } - for (GlobalId node_id : resolveNodeTarget(domain, boundary.target)) { - if (domain.nodes.count(node_id) == 0) { - continue; - } - for (int dof = boundary.first_dof; dof <= boundary.last_dof; ++dof) { - constrained_dofs.insert(std::make_pair(node_id, dof - 1)); - } - } - } - - std::set active_connectivity_nodes; - for (const auto& [element_id, element] : domain.elements) { - (void)element_id; - for (GlobalId node_id : element.node_ids) { - if (domain.nodes.count(node_id) != 0) { - active_connectivity_nodes.insert(node_id); - } - } - } - - LocalIndex free_dof_count = 0; - LocalIndex weak_drilling_count = 0; - GlobalId weak_drilling_example = 0; - for (const auto& [node_id, node] : domain.nodes) { - (void)node; - for (Dof dof : allDofs()) { - const auto key = std::make_pair(node_id, dofIndex(dof)); - if (constrained_dofs.count(key) != 0) { - continue; - } - ++free_dof_count; - if (!domain.elements.empty() && active_connectivity_nodes.count(node_id) == 0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-SINGULAR-DOF-UNTOUCHED", - "Node " + std::to_string(node_id) + " DOF " + dofLabel(dof) + - " is free but is not touched by active element connectivity", - "dof")); - } - if (!domain.elements.empty() && active_connectivity_nodes.count(node_id) != 0 && dof == Dof::RZ) { - if (weak_drilling_count == 0) { - weak_drilling_example = node_id; - } - ++weak_drilling_count; - } - } - } - if (!domain.nodes.empty() && free_dof_count == 0) { - diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-SINGULAR-NO-FREE-DOFS", - "No free DOFs exist after applying boundary constraints", "dof")); - } - if (weak_drilling_count > 0) { - diagnostics.push_back(makeDiagnostic(Severity::Warning, "FESA-SINGULAR-WEAK-DRILLING-DOF", - "Node " + std::to_string(weak_drilling_example) + - " DOF RZ is free; drilling rotation is weakly stabilized in Phase 1 (" + - std::to_string(weak_drilling_count) + " free drilling DOF(s))", - "dof")); - } - return diagnostics; -} - -struct AnalysisModel { - StepDefinition step; - std::vector active_element_ids; - std::vector active_boundary_condition_indices; - std::vector active_load_indices; - std::vector active_shell_section_indices; - std::vector active_material_keys; - std::vector diagnostics; - - bool ok() const { - return !hasError(diagnostics); - } -}; - -inline AnalysisModel buildLinearStaticAnalysisModel(const Domain& domain, LocalIndex step_index = 0) { - AnalysisModel model; - if (domain.steps.empty()) { - model.step = {"Step-1", "linear_static"}; - } else { - if (step_index < 0 || step_index >= static_cast(domain.steps.size())) { - model.diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-ANALYSIS-STEP-INDEX", - "Requested analysis step index is out of range", "analysis model")); - model.step = domain.steps.front(); - } else { - model.step = domain.steps[static_cast(step_index)]; - } - } - if (domain.steps.size() > 1) { - model.diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-ANALYSIS-MULTIPLE-STEPS", - "Phase 1 execution supports one active linear static step", "analysis model")); - } - if (model.step.analysis_type != "linear_static") { - model.diagnostics.push_back(makeDiagnostic(Severity::Error, "FESA-ANALYSIS-UNSUPPORTED-STEP", - "Only linear static steps are supported in Phase 1", "analysis model")); - } - for (const auto& [element_id, element] : domain.elements) { - (void)element; - model.active_element_ids.push_back(element_id); - } - for (std::size_t i = 0; i < domain.boundary_conditions.size(); ++i) { - model.active_boundary_condition_indices.push_back(i); - } - for (std::size_t i = 0; i < domain.loads.size(); ++i) { - model.active_load_indices.push_back(i); - } - for (std::size_t i = 0; i < domain.shell_sections.size(); ++i) { - model.active_shell_section_indices.push_back(i); - } - for (const auto& [material_key, material] : domain.materials) { - (void)material; - model.active_material_keys.push_back(material_key); - } - return model; -} - -struct DofAddress { - GlobalId node_id = 0; - Dof dof = Dof::UX; -}; - -class DofManager { - public: - explicit DofManager(const Domain& domain) { - for (const auto& [node_id, node] : domain.nodes) { - (void)node; - node_ids_.push_back(node_id); - for (Dof dof : allDofs()) { - const LocalIndex full_index = static_cast(all_dofs_.size()); - const auto key = std::make_pair(node_id, dofIndex(dof)); - all_dofs_.push_back(key); - full_index_by_key_[key] = full_index; - } - } - for (const BoundaryCondition& boundary : domain.boundary_conditions) { - if (!validAbaqusDofRange(boundary.first_dof, boundary.last_dof)) { - continue; - } - for (GlobalId node_id : resolveNodeTarget(domain, boundary.target)) { - for (int dof = boundary.first_dof; dof <= boundary.last_dof; ++dof) { - constrained_.insert(std::make_pair(node_id, dof - 1)); - } - } - } - for (const auto& key : all_dofs_) { - const LocalIndex full_index = full_index_by_key_.at(key); - if (constrained_.count(key) == 0) { - equation_by_key_[key] = static_cast(free_full_indices_.size()); - free_full_indices_.push_back(full_index); - } else { - equation_by_key_[key] = -1; - constrained_full_indices_.push_back(full_index); - } - } - } - - LocalIndex fullDofCount() const { - return static_cast(all_dofs_.size()); - } - - LocalIndex freeDofCount() const { - return static_cast(free_full_indices_.size()); - } - - LocalIndex constrainedDofCount() const { - return static_cast(constrained_full_indices_.size()); - } - - const std::vector& nodeIds() const { - return node_ids_; - } - - const std::vector& freeFullIndices() const { - return free_full_indices_; - } - - const std::vector& constrainedFullIndices() const { - return constrained_full_indices_; - } - - DofAddress fullDof(LocalIndex full_index) const { - const auto& key = all_dofs_.at(static_cast(full_index)); - return {key.first, static_cast(key.second)}; - } - - LocalIndex fullIndex(GlobalId node_id, Dof dof) const { - return full_index_by_key_.at(std::make_pair(node_id, dofIndex(dof))); - } - - EquationId equation(GlobalId node_id, Dof dof) const { - return equation_by_key_.at(std::make_pair(node_id, dofIndex(dof))); - } - - bool isConstrained(GlobalId node_id, Dof dof) const { - return constrained_.count(std::make_pair(node_id, dofIndex(dof))) != 0; - } - - std::vector reduceFullVector(const std::vector& full) const { - std::vector reduced; - reduced.reserve(free_full_indices_.size()); - for (LocalIndex full_index : free_full_indices_) { - reduced.push_back(full.at(static_cast(full_index))); - } - return reduced; - } - - std::vector reconstructFullVector(const std::vector& reduced) const { - std::vector full(static_cast(fullDofCount()), 0.0); - for (std::size_t i = 0; i < free_full_indices_.size(); ++i) { - full[static_cast(free_full_indices_[i])] = reduced.at(i); - } - return full; - } - - std::array elementFullDofIndices(const Element& element) const { - std::array indices{}; - for (LocalIndex node = 0; node < 4; ++node) { - for (Dof dof : allDofs()) { - const LocalIndex local = 6 * node + dofIndex(dof); - indices[static_cast(local)] = fullIndex(element.node_ids[static_cast(node)], dof); - } - } - return indices; - } - - std::array elementEquationIds(const Element& element) const { - std::array equations{}; - for (LocalIndex node = 0; node < 4; ++node) { - for (Dof dof : allDofs()) { - const LocalIndex local = 6 * node + dofIndex(dof); - equations[static_cast(local)] = equation(element.node_ids[static_cast(node)], dof); - } - } - return equations; - } - - private: - std::vector node_ids_; - std::vector> all_dofs_; - std::set> constrained_; - std::map, LocalIndex> full_index_by_key_; - std::map, EquationId> equation_by_key_; - std::vector free_full_indices_; - std::vector constrained_full_indices_; -}; - struct SparsePatternEntry { EquationId row = 0; EquationId col = 0; @@ -2419,14 +1789,6 @@ class InMemoryResultsWriter { ResultFile result_; }; -struct AnalysisState { - std::vector u_full; - std::vector f_external_full; - std::vector f_internal_full; - std::vector reaction_full; - bool converged = false; -}; - struct AnalysisResult { AnalysisModel model; AnalysisState state; diff --git a/phases/1-structure-alignment-refactor/index.json b/phases/1-structure-alignment-refactor/index.json index 65a1564..2bbafad 100644 --- a/phases/1-structure-alignment-refactor/index.json +++ b/phases/1-structure-alignment-refactor/index.json @@ -4,7 +4,7 @@ "steps": [ { "step": 0, "name": "architecture-drift-audit", "status": "completed", "artifact": "step0-architecture-map.md" }, { "step": 1, "name": "module-scaffold-and-facade", "status": "completed" }, - { "step": 2, "name": "core-domain-dof-extraction", "status": "pending" }, + { "step": 2, "name": "core-domain-dof-extraction", "status": "completed" }, { "step": 3, "name": "math-solver-extraction", "status": "pending" }, { "step": 4, "name": "io-parser-extraction", "status": "pending" }, { "step": 5, "name": "results-reference-extraction", "status": "pending" }, diff --git a/tests/test_core_module_includes.cpp b/tests/test_core_module_includes.cpp new file mode 100644 index 0000000..465b044 --- /dev/null +++ b/tests/test_core_module_includes.cpp @@ -0,0 +1,69 @@ +#include "fesa/Boundary/Boundary.hpp" +#include "fesa/Core/Core.hpp" +#include "fesa/Load/Load.hpp" +#include "fesa/Property/Property.hpp" +#include "fesa/Util/Util.hpp" + +#include +#include +#include +#include + +namespace { + +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +} // namespace + +int main() { + static_assert(std::is_same_v, "Real must remain double"); + static_assert(std::is_same_v, "GlobalId must remain int64"); + static_assert(std::is_same_v, "LocalIndex must remain int64"); + static_assert(std::is_same_v, "EquationId must remain int64"); + static_assert(std::is_same_v, "SparseIndex must remain int64"); + + fesa::Domain domain; + domain.nodes[10] = {10, {0.0, 0.0, 0.0}}; + domain.nodes[20] = {20, {1.0, 0.0, 0.0}}; + domain.nodes[30] = {30, {1.0, 1.0, 0.0}}; + domain.nodes[40] = {40, {0.0, 1.0, 0.0}}; + domain.elements[100] = {100, fesa::ElementType::MITC4, {10, 20, 30, 40}, "EALL"}; + domain.element_sets["eall"] = {"EALL", {100}}; + domain.node_sets["fixed"] = {"FIXED", {10, 40}}; + domain.materials["mat"] = {"MAT", 1000.0, 0.3}; + domain.shell_sections.push_back(fesa::ShellSection{"EALL", "MAT", 0.1}); + domain.boundary_conditions.push_back(fesa::BoundaryCondition{"FIXED", 1, 6, 0.0}); + domain.loads.push_back(fesa::NodalLoad{"20", 3, -1.0}); + domain.steps.push_back({"Step-1", "linear_static"}); + + check(fesa::Domain::key(" EALL ") == "eall", "Domain key normalization changed"); + check(fesa::dofFromAbaqus(6).value() == fesa::Dof::RZ, "Abaqus DOF mapping changed"); + check(std::string(fesa::dofLabel(fesa::Dof::UX)) == "UX", "DOF label changed"); + + const auto diagnostics = fesa::validateDomain(domain); + check(!fesa::hasError(diagnostics), "Direct module validation reported an error"); + + const fesa::DofManager dofs(domain); + check(dofs.fullDofCount() == 24, "DofManager full DOF count changed"); + check(dofs.constrainedDofCount() == 12, "DofManager constrained DOF count changed"); + check(dofs.freeDofCount() == 12, "DofManager free DOF count changed"); + check(dofs.equation(20, fesa::Dof::UZ) >= 0, "Free equation numbering changed"); + check(dofs.equation(10, fesa::Dof::UX) == -1, "Constrained equation numbering changed"); + + const auto diagnostic = fesa::makeDiagnostic(fesa::Severity::Warning, "FESA-SMOKE", "module smoke", "core"); + check(diagnostic.source.keyword == "core", "Diagnostic source keyword changed"); + + const auto model = fesa::buildLinearStaticAnalysisModel(domain); + check(model.ok(), "AnalysisModel direct module construction failed"); + check(model.active_element_ids.size() == 1, "AnalysisModel active elements changed"); + + fesa::AnalysisState state; + state.u_full = dofs.reconstructFullVector(std::vector(static_cast(dofs.freeDofCount()), 0.0)); + check(state.u_full.size() == static_cast(dofs.fullDofCount()), "AnalysisState full vector ownership changed"); + + return 0; +}