From 421ad5a70785d3c305f5e986bd366d6e9d49f5b4 Mon Sep 17 00:00:00 2001 From: NINI Date: Tue, 5 May 2026 01:38:04 +0900 Subject: [PATCH] refactor: extract results reference comparison --- CMakeLists.txt | 7 + PLAN.md | 6 +- PROGRESS.md | 36 ++- include/fesa/Results/ReferenceComparison.hpp | 177 ++++++++++++ include/fesa/Results/ResultModel.hpp | 123 +++++++++ include/fesa/Results/Results.hpp | 2 + include/fesa/fesa.hpp | 253 +----------------- .../1-structure-alignment-refactor/index.json | 2 +- tests/test_results_module_includes.cpp | 120 +++++++++ 9 files changed, 469 insertions(+), 257 deletions(-) create mode 100644 include/fesa/Results/ReferenceComparison.hpp create mode 100644 include/fesa/Results/ResultModel.hpp create mode 100644 tests/test_results_module_includes.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index dc3adde..5487834 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,21 +28,28 @@ 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}") +add_executable(fesa_results_module_tests tests/test_results_module_includes.cpp) +target_link_libraries(fesa_results_module_tests PRIVATE fesa_core) +target_compile_definitions(fesa_results_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-) + target_compile_options(fesa_results_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) + target_compile_options(fesa_results_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) +add_test(NAME fesa_results_module_tests COMMAND fesa_results_module_tests) diff --git a/PLAN.md b/PLAN.md index 425affd..67d9de7 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-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. +Execute the Phase 1 structure-alignment refactor in `phases/1-structure-alignment-refactor`, continuing with P1A-06 MITC4 geometry/strain 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, P1A-04 extracted the Abaqus Phase 1 parser into IO, and P1A-05 extracted Results/reference comparison 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` 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. +- 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` is complete and extracted Results/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`. @@ -62,7 +62,7 @@ This phase is an architecture-preserving refactor. It must not change Phase 1 so | 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 | 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-05 | completed | 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 | | P1A-08 | pending | generator | Extract Assembly and Analysis workflow. | P1A-02, P1A-03, P1A-05, P1A-07 | Full-vector RF, solver injection, and end-to-end reference regression unchanged | diff --git a/PROGRESS.md b/PROGRESS.md index d23e365..2b2a23f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,10 +13,44 @@ 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-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. +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-05 are complete, so the next step is P1A-06 MITC4 geometry/strain 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-05 Results reference extraction completed +Author: Codex + +Changed files: +- `CMakeLists.txt` +- `include/fesa/Results/ReferenceComparison.hpp` +- `include/fesa/Results/ResultModel.hpp` +- `include/fesa/Results/Results.hpp` +- `include/fesa/fesa.hpp` +- `tests/test_results_module_includes.cpp` +- `phases/1-structure-alignment-refactor/index.json` +- `PLAN.md` +- `PROGRESS.md` + +Summary: +- Extracted `FieldOutput`, `ResultFrame`, `ResultStep`, `ResultFile`, and `InMemoryResultsWriter` from the umbrella header into `include/fesa/Results/ResultModel.hpp`. +- Extracted displacement CSV loading, `ComparisonOptions`, `ComparisonResult`, and `compareDisplacements` into `include/fesa/Results/ReferenceComparison.hpp`. +- Updated `include/fesa/Results/Results.hpp` to expose the in-memory result model and reference comparator through the Results module. +- Updated `include/fesa/fesa.hpp` to include the Results module facade, preserving existing umbrella consumers and leaving Analysis workflow symbols in place for P1A-08. +- Added `fesa_results_module_tests`, a direct Results include smoke/regression test that does not include `fesa/fesa.hpp`. +- Preserved mandatory Phase 1 `U`/`RF` metadata, CSV column mapping, node-id-based matching, and the stored `quad_02_displacements.csv` loader path. +- No HDF5 dependency, reaction CSV parser, result label change, comparison tolerance change, or analysis execution change was introduced. +- Remaining large groups in `fesa.hpp` are Assembly helpers (`buildReducedSparsePattern`, `recoverFullReaction`), MITC4 Element/Material helpers, and Analysis workflow. + +Verification: +- First ran `python scripts\validate_workspace.py` after adding the direct Results include test; it failed as expected because `fesa/Results/Results.hpp` did not yet expose result and comparison symbols. +- After extraction, `python scripts\validate_workspace.py` configured CMake, built `fesa_core`, `fesa_tests`, `fesa_core_module_tests`, `fesa_math_module_tests`, `fesa_io_module_tests`, and `fesa_results_module_tests`, and ran CTest successfully. +- CTest result: 5 test executables passed. + +Follow-up: +- Continue with P1A-06 MITC4 geometry/strain extraction. +- Keep R-014 open until P1A-09 independently accepts the final architecture alignment. +- Keep R-010 open; missing Abaqus reaction CSV artifacts are not solved by this refactor. + ### 2026-05-05 - P1A-04 IO parser extraction completed Author: Codex diff --git a/include/fesa/Results/ReferenceComparison.hpp b/include/fesa/Results/ReferenceComparison.hpp new file mode 100644 index 0000000..d0382be --- /dev/null +++ b/include/fesa/Results/ReferenceComparison.hpp @@ -0,0 +1,177 @@ +#pragma once + +#include "fesa/Results/ResultModel.hpp" +#include "fesa/Util/Util.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { + +struct CsvDisplacementRow { + GlobalId node_id = 0; + std::array values{}; +}; + +struct CsvDisplacementTable { + std::map rows; + std::vector diagnostics; +}; + +inline std::vector displacementCsvRequiredColumns() { + return {"Node Label", "U-U1", "U-U2", "U-U3", "UR-UR1", "UR-UR2", "UR-UR3"}; +} + +inline CsvDisplacementTable loadDisplacementCsvFromStream(std::istream& input, const std::string& source_name) { + CsvDisplacementTable table; + std::string line; + if (!std::getline(input, line)) { + table.diagnostics.push_back({Severity::Error, "FESA-CSV-EMPTY", "Displacement CSV is empty", {source_name, 1, ""}}); + return table; + } + const std::vector required = displacementCsvRequiredColumns(); + std::vector headers = splitCsv(line); + std::map column; + for (std::size_t i = 0; i < headers.size(); ++i) { + column[trim(headers[i])] = i; + } + for (const std::string& name : required) { + if (column.count(name) == 0) { + table.diagnostics.push_back({Severity::Error, "FESA-CSV-MISSING-COLUMN", "Missing CSV column: " + name, {source_name, 1, ""}}); + } + } + if (hasError(table.diagnostics)) { + return table; + } + LocalIndex line_number = 1; + while (std::getline(input, line)) { + ++line_number; + if (trim(line).empty()) { + continue; + } + std::vector fields = splitCsv(line); + auto get = [&](const std::string& name) -> std::string { + const std::size_t index = column[name]; + return index < fields.size() ? fields[index] : ""; + }; + auto node_id = parseInt64(get("Node Label")); + if (!node_id) { + table.diagnostics.push_back({Severity::Error, "FESA-CSV-NODE", "Invalid node label", {source_name, line_number, ""}}); + continue; + } + if (table.rows.count(*node_id) != 0) { + table.diagnostics.push_back({Severity::Error, "FESA-CSV-DUPLICATE-NODE", "Duplicate node label", {source_name, line_number, ""}}); + continue; + } + CsvDisplacementRow row; + row.node_id = *node_id; + for (std::size_t i = 0; i < 6; ++i) { + auto value = parseReal(get(required[i + 1])); + if (!value) { + table.diagnostics.push_back({Severity::Error, "FESA-CSV-NUMERIC", "Invalid displacement value", {source_name, line_number, ""}}); + value = 0.0; + } + row.values[i] = *value; + } + table.rows[*node_id] = row; + } + return table; +} + +inline CsvDisplacementTable loadDisplacementCsvFromString(const std::string& text, const std::string& source_name = "") { + std::istringstream input(text); + return loadDisplacementCsvFromStream(input, source_name); +} + +inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { + std::ifstream input(path); + if (!input.good()) { + CsvDisplacementTable table; + table.diagnostics.push_back({Severity::Error, "FESA-CSV-READ", "Could not read displacement CSV", {path, 0, ""}}); + return table; + } + return loadDisplacementCsvFromStream(input, path); +} + +struct ComparisonOptions { + Real abs_tol = 1.0e-12; + Real rel_tol = 1.0e-5; + Real reference_scale = 1.0; +}; + +struct ComparisonResult { + bool pass = false; + Real max_abs_error = 0.0; + Real max_rel_error = 0.0; + std::vector diagnostics; +}; + +inline ComparisonResult compareDisplacements(const FieldOutput& actual, + const CsvDisplacementTable& expected, + ComparisonOptions options = {}) { + ComparisonResult result; + result.diagnostics = expected.diagnostics; + if (hasError(result.diagnostics)) { + return result; + } + if (actual.name != "U") { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-NAME", "Expected FESA displacement field named U", {}}); + } + if (actual.component_labels != displacementComponentLabels()) { + result.diagnostics.push_back({Severity::Error, + "FESA-COMPARE-COMPONENT-LABELS", + "FESA U field component labels must be UX,UY,UZ,RX,RY,RZ", + {}}); + } + if (actual.position != "NODAL" || actual.entity_type != "node" || actual.basis != "GLOBAL") { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-METADATA", "FESA U field must be nodal values in the global basis", {}}); + } + if (actual.entity_ids.size() != actual.values.size()) { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-SIZE", "FESA U field entity/value counts differ", {}}); + } + std::map> actual_by_node; + const std::size_t actual_count = std::min(actual.entity_ids.size(), actual.values.size()); + for (std::size_t i = 0; i < actual_count; ++i) { + if (actual_by_node.count(actual.entity_ids[i]) != 0) { + result.diagnostics.push_back( + {Severity::Error, "FESA-COMPARE-DUPLICATE-ACTUAL", "FESA U field contains duplicate node " + std::to_string(actual.entity_ids[i]), {}}); + continue; + } + actual_by_node[actual.entity_ids[i]] = actual.values[i]; + } + for (const auto& [node_id, row] : expected.rows) { + auto actual_it = actual_by_node.find(node_id); + if (actual_it == actual_by_node.end()) { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-MISSING-ACTUAL", "FESA U field is missing node " + std::to_string(node_id), {}}); + continue; + } + for (std::size_t component = 0; component < 6; ++component) { + const Real expected_value = row.values[component]; + const Real actual_value = actual_it->second[component]; + const Real abs_error = std::fabs(actual_value - expected_value); + const Real scale = std::max(std::fabs(expected_value), std::fabs(options.reference_scale)); + const Real rel_error = scale > 0.0 ? abs_error / scale : (abs_error == 0.0 ? 0.0 : std::numeric_limits::infinity()); + result.max_abs_error = std::max(result.max_abs_error, abs_error); + result.max_rel_error = std::max(result.max_rel_error, rel_error); + if (!(abs_error <= options.abs_tol || rel_error <= options.rel_tol)) { + const std::string component_label = displacementComponentLabels()[component]; + result.diagnostics.push_back({Severity::Error, + "FESA-COMPARE-TOLERANCE", + "Displacement comparison failed at node " + std::to_string(node_id) + " component " + component_label, + {}}); + } + } + } + result.pass = !hasError(result.diagnostics); + return result; +} + +} // namespace fesa diff --git a/include/fesa/Results/ResultModel.hpp b/include/fesa/Results/ResultModel.hpp new file mode 100644 index 0000000..fc0e5fd --- /dev/null +++ b/include/fesa/Results/ResultModel.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "fesa/Core/Core.hpp" + +#include +#include +#include +#include + +namespace fesa { + +struct FieldOutput { + std::string name; + std::string position = "NODAL"; + std::string entity_type = "node"; + std::string basis = "GLOBAL"; + std::string description; + std::vector entity_ids; + std::vector component_labels; + std::vector> values; +}; + +struct ResultFrame { + LocalIndex frame_id = 0; + LocalIndex increment = 1; + LocalIndex iteration = 0; + Real step_time = 1.0; + Real total_time = 1.0; + bool converged = true; + std::string description = "Phase 1 linear static frame"; + std::map field_outputs; +}; + +struct ResultStep { + std::string name; + std::vector frames; +}; + +struct ResultFile { + std::string schema_name = "FESA_RESULTS"; + LocalIndex schema_version = 1; + std::string solver_name = "FESA"; + std::string dof_convention = "UX,UY,UZ,RX,RY,RZ"; + std::string sign_convention = "Abaqus-compatible"; + std::string precision = "double"; + std::string index_type = "int64"; + std::vector node_ids; + std::vector coordinates; + std::vector element_ids; + std::vector element_types; + std::vector> connectivity; + std::vector steps; +}; + +class InMemoryResultsWriter { + public: + void writeLinearStatic(const Domain& domain, + const DofManager& dofs, + const std::vector& u_full, + const std::vector& rf_full) { + const auto model = buildLinearStaticAnalysisModel(domain); + writeLinearStatic(domain, model, dofs, u_full, rf_full); + } + + void writeLinearStatic(const Domain& domain, + const AnalysisModel& model, + const DofManager& dofs, + const std::vector& u_full, + const std::vector& rf_full) { + result_ = ResultFile{}; + for (const auto& [node_id, node] : domain.nodes) { + result_.node_ids.push_back(node_id); + result_.coordinates.push_back(node.coordinates); + } + for (const auto& [element_id, element] : domain.elements) { + result_.element_ids.push_back(element_id); + result_.element_types.push_back(elementTypeLabel(element.type)); + result_.connectivity.push_back(element.node_ids); + } + ResultStep step; + step.name = model.step.name.empty() ? "Step-1" : model.step.name; + ResultFrame frame; + frame.frame_id = 0; + frame.field_outputs["U"] = buildNodalField("U", displacementComponentLabels(), "Nodal displacement and rotation", domain, dofs, u_full); + frame.field_outputs["RF"] = buildNodalField("RF", reactionComponentLabels(), "Nodal reaction force and moment", domain, dofs, rf_full); + step.frames.push_back(frame); + result_.steps.push_back(step); + } + + const ResultFile& result() const { + return result_; + } + + private: + static FieldOutput buildNodalField(const std::string& name, + const std::vector& labels, + const std::string& description, + const Domain& domain, + const DofManager& dofs, + const std::vector& full_values) { + FieldOutput field; + field.name = name; + field.position = "NODAL"; + field.entity_type = "node"; + field.basis = "GLOBAL"; + field.description = description; + field.component_labels = labels; + for (const auto& [node_id, node] : domain.nodes) { + (void)node; + field.entity_ids.push_back(node_id); + std::array values{}; + for (Dof dof : allDofs()) { + values[static_cast(dofIndex(dof))] = full_values[static_cast(dofs.fullIndex(node_id, dof))]; + } + field.values.push_back(values); + } + return field; + } + + ResultFile result_; +}; + +} // namespace fesa diff --git a/include/fesa/Results/Results.hpp b/include/fesa/Results/Results.hpp index b24e9c7..712b8b0 100644 --- a/include/fesa/Results/Results.hpp +++ b/include/fesa/Results/Results.hpp @@ -1,6 +1,8 @@ #pragma once #include "fesa/ModuleInfo.hpp" +#include "fesa/Results/ResultModel.hpp" +#include "fesa/Results/ReferenceComparison.hpp" namespace fesa::module { diff --git a/include/fesa/fesa.hpp b/include/fesa/fesa.hpp index 07b7a79..f3b654b 100644 --- a/include/fesa/fesa.hpp +++ b/include/fesa/fesa.hpp @@ -7,6 +7,7 @@ #include "fesa/Math/Math.hpp" #include "fesa/ModuleInfo.hpp" #include "fesa/Property/Property.hpp" +#include "fesa/Results/Results.hpp" #include "fesa/Util/Util.hpp" #include @@ -1069,107 +1070,6 @@ inline ReducedSystem projectToReducedSystem(const AssemblyResult& assembly, cons return result; } -struct FieldOutput { - std::string name; - std::string position = "NODAL"; - std::string entity_type = "node"; - std::string basis = "GLOBAL"; - std::string description; - std::vector entity_ids; - std::vector component_labels; - std::vector> values; -}; - -struct ResultFrame { - LocalIndex frame_id = 0; - LocalIndex increment = 1; - LocalIndex iteration = 0; - Real step_time = 1.0; - Real total_time = 1.0; - bool converged = true; - std::string description = "Phase 1 linear static frame"; - std::map field_outputs; -}; - -struct ResultStep { - std::string name; - std::vector frames; -}; - -struct ResultFile { - std::string schema_name = "FESA_RESULTS"; - LocalIndex schema_version = 1; - std::string solver_name = "FESA"; - std::string dof_convention = "UX,UY,UZ,RX,RY,RZ"; - std::string sign_convention = "Abaqus-compatible"; - std::string precision = "double"; - std::string index_type = "int64"; - std::vector node_ids; - std::vector coordinates; - std::vector element_ids; - std::vector element_types; - std::vector> connectivity; - std::vector steps; -}; - -class InMemoryResultsWriter { - public: - void writeLinearStatic(const Domain& domain, const DofManager& dofs, const std::vector& u_full, const std::vector& rf_full) { - const auto model = buildLinearStaticAnalysisModel(domain); - writeLinearStatic(domain, model, dofs, u_full, rf_full); - } - - void writeLinearStatic(const Domain& domain, const AnalysisModel& model, const DofManager& dofs, - const std::vector& u_full, const std::vector& rf_full) { - result_ = ResultFile{}; - for (const auto& [node_id, node] : domain.nodes) { - result_.node_ids.push_back(node_id); - result_.coordinates.push_back(node.coordinates); - } - for (const auto& [element_id, element] : domain.elements) { - result_.element_ids.push_back(element_id); - result_.element_types.push_back(elementTypeLabel(element.type)); - result_.connectivity.push_back(element.node_ids); - } - ResultStep step; - step.name = model.step.name.empty() ? "Step-1" : model.step.name; - ResultFrame frame; - frame.frame_id = 0; - frame.field_outputs["U"] = buildNodalField("U", displacementComponentLabels(), "Nodal displacement and rotation", domain, dofs, u_full); - frame.field_outputs["RF"] = buildNodalField("RF", reactionComponentLabels(), "Nodal reaction force and moment", domain, dofs, rf_full); - step.frames.push_back(frame); - result_.steps.push_back(step); - } - - const ResultFile& result() const { - return result_; - } - - private: - static FieldOutput buildNodalField(const std::string& name, const std::vector& labels, const std::string& description, - const Domain& domain, const DofManager& dofs, const std::vector& full_values) { - FieldOutput field; - field.name = name; - field.position = "NODAL"; - field.entity_type = "node"; - field.basis = "GLOBAL"; - field.description = description; - field.component_labels = labels; - for (const auto& [node_id, node] : domain.nodes) { - (void)node; - field.entity_ids.push_back(node_id); - std::array values{}; - for (Dof dof : allDofs()) { - values[static_cast(dofIndex(dof))] = full_values[static_cast(dofs.fullIndex(node_id, dof))]; - } - field.values.push_back(values); - } - return field; - } - - ResultFile result_; -}; - struct AnalysisResult { AnalysisModel model; AnalysisState state; @@ -1273,155 +1173,4 @@ inline AnalysisResult runLinearStaticInputString(const std::string& text, return analysis.run(parsed.domain); } -struct CsvDisplacementRow { - GlobalId node_id = 0; - std::array values{}; -}; - -struct CsvDisplacementTable { - std::map rows; - std::vector diagnostics; -}; - -inline std::vector displacementCsvRequiredColumns() { - return {"Node Label", "U-U1", "U-U2", "U-U3", "UR-UR1", "UR-UR2", "UR-UR3"}; -} - -inline CsvDisplacementTable loadDisplacementCsvFromStream(std::istream& input, const std::string& source_name) { - CsvDisplacementTable table; - std::string line; - if (!std::getline(input, line)) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-EMPTY", "Displacement CSV is empty", {source_name, 1, ""}}); - return table; - } - const std::vector required = displacementCsvRequiredColumns(); - std::vector headers = splitCsv(line); - std::map column; - for (std::size_t i = 0; i < headers.size(); ++i) { - column[trim(headers[i])] = i; - } - for (const std::string& name : required) { - if (column.count(name) == 0) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-MISSING-COLUMN", "Missing CSV column: " + name, {source_name, 1, ""}}); - } - } - if (hasError(table.diagnostics)) { - return table; - } - LocalIndex line_number = 1; - while (std::getline(input, line)) { - ++line_number; - if (trim(line).empty()) { - continue; - } - std::vector fields = splitCsv(line); - auto get = [&](const std::string& name) -> std::string { - const std::size_t index = column[name]; - return index < fields.size() ? fields[index] : ""; - }; - auto node_id = parseInt64(get("Node Label")); - if (!node_id) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-NODE", "Invalid node label", {source_name, line_number, ""}}); - continue; - } - if (table.rows.count(*node_id) != 0) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-DUPLICATE-NODE", "Duplicate node label", {source_name, line_number, ""}}); - continue; - } - CsvDisplacementRow row; - row.node_id = *node_id; - for (std::size_t i = 0; i < 6; ++i) { - auto value = parseReal(get(required[i + 1])); - if (!value) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-NUMERIC", "Invalid displacement value", {source_name, line_number, ""}}); - value = 0.0; - } - row.values[i] = *value; - } - table.rows[*node_id] = row; - } - return table; -} - -inline CsvDisplacementTable loadDisplacementCsvFromString(const std::string& text, const std::string& source_name = "") { - std::istringstream input(text); - return loadDisplacementCsvFromStream(input, source_name); -} - -inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { - std::ifstream input(path); - if (!input.good()) { - CsvDisplacementTable table; - table.diagnostics.push_back({Severity::Error, "FESA-CSV-READ", "Could not read displacement CSV", {path, 0, ""}}); - return table; - } - return loadDisplacementCsvFromStream(input, path); -} - -struct ComparisonOptions { - Real abs_tol = 1.0e-12; - Real rel_tol = 1.0e-5; - Real reference_scale = 1.0; -}; - -struct ComparisonResult { - bool pass = false; - Real max_abs_error = 0.0; - Real max_rel_error = 0.0; - std::vector diagnostics; -}; - -inline ComparisonResult compareDisplacements(const FieldOutput& actual, const CsvDisplacementTable& expected, ComparisonOptions options = {}) { - ComparisonResult result; - result.diagnostics = expected.diagnostics; - if (hasError(result.diagnostics)) { - return result; - } - if (actual.name != "U") { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-NAME", "Expected FESA displacement field named U", {}}); - } - if (actual.component_labels != displacementComponentLabels()) { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-COMPONENT-LABELS", "FESA U field component labels must be UX,UY,UZ,RX,RY,RZ", {}}); - } - if (actual.position != "NODAL" || actual.entity_type != "node" || actual.basis != "GLOBAL") { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-METADATA", "FESA U field must be nodal values in the global basis", {}}); - } - if (actual.entity_ids.size() != actual.values.size()) { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-SIZE", "FESA U field entity/value counts differ", {}}); - } - std::map> actual_by_node; - const std::size_t actual_count = std::min(actual.entity_ids.size(), actual.values.size()); - for (std::size_t i = 0; i < actual_count; ++i) { - if (actual_by_node.count(actual.entity_ids[i]) != 0) { - result.diagnostics.push_back( - {Severity::Error, "FESA-COMPARE-DUPLICATE-ACTUAL", "FESA U field contains duplicate node " + std::to_string(actual.entity_ids[i]), {}}); - continue; - } - actual_by_node[actual.entity_ids[i]] = actual.values[i]; - } - for (const auto& [node_id, row] : expected.rows) { - auto actual_it = actual_by_node.find(node_id); - if (actual_it == actual_by_node.end()) { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-MISSING-ACTUAL", "FESA U field is missing node " + std::to_string(node_id), {}}); - continue; - } - for (std::size_t component = 0; component < 6; ++component) { - const Real expected_value = row.values[component]; - const Real actual_value = actual_it->second[component]; - const Real abs_error = std::fabs(actual_value - expected_value); - const Real scale = std::max(std::fabs(expected_value), std::fabs(options.reference_scale)); - const Real rel_error = scale > 0.0 ? abs_error / scale : (abs_error == 0.0 ? 0.0 : std::numeric_limits::infinity()); - result.max_abs_error = std::max(result.max_abs_error, abs_error); - result.max_rel_error = std::max(result.max_rel_error, rel_error); - if (!(abs_error <= options.abs_tol || rel_error <= options.rel_tol)) { - const std::string component_label = displacementComponentLabels()[component]; - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-TOLERANCE", - "Displacement comparison failed at node " + std::to_string(node_id) + " component " + component_label, {}}); - } - } - } - result.pass = !hasError(result.diagnostics); - return result; -} - } // namespace fesa diff --git a/phases/1-structure-alignment-refactor/index.json b/phases/1-structure-alignment-refactor/index.json index d1df354..94fe961 100644 --- a/phases/1-structure-alignment-refactor/index.json +++ b/phases/1-structure-alignment-refactor/index.json @@ -7,7 +7,7 @@ { "step": 2, "name": "core-domain-dof-extraction", "status": "completed" }, { "step": 3, "name": "math-solver-extraction", "status": "completed" }, { "step": 4, "name": "io-parser-extraction", "status": "completed" }, - { "step": 5, "name": "results-reference-extraction", "status": "pending" }, + { "step": 5, "name": "results-reference-extraction", "status": "completed" }, { "step": 6, "name": "mitc4-geometry-strain-extraction", "status": "pending" }, { "step": 7, "name": "mitc4-material-stiffness-extraction", "status": "pending" }, { "step": 8, "name": "assembly-analysis-extraction", "status": "pending" }, diff --git a/tests/test_results_module_includes.cpp b/tests/test_results_module_includes.cpp new file mode 100644 index 0000000..af877c9 --- /dev/null +++ b/tests/test_results_module_includes.cpp @@ -0,0 +1,120 @@ +#include "fesa/Results/Results.hpp" + +#include +#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 +} + +fesa::Domain makeDomain() { + fesa::Domain domain; + domain.nodes[1] = {1, {0.0, 0.0, 0.0}}; + domain.nodes[2] = {2, {1.0, 0.0, 0.0}}; + domain.nodes[3] = {3, {1.0, 1.0, 0.0}}; + domain.nodes[4] = {4, {0.0, 1.0, 0.0}}; + domain.elements[10] = {10, fesa::ElementType::MITC4, {1, 2, 3, 4}, "all_elements"}; + domain.steps.push_back({"Step-Results", "linear_static"}); + domain.boundary_conditions.push_back({"1", 1, 6, 0.0}); + return domain; +} + +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 domain = makeDomain(); + const fesa::DofManager dofs(domain); + std::vector u(static_cast(dofs.fullDofCount()), 0.0); + std::vector rf(static_cast(dofs.fullDofCount()), 0.0); + u[static_cast(dofs.fullIndex(2, fesa::Dof::UZ))] = -0.25; + rf[static_cast(dofs.fullIndex(1, fesa::Dof::UZ))] = 3.0; + + fesa::InMemoryResultsWriter writer; + writer.writeLinearStatic(domain, dofs, u, rf); + const auto& result = writer.result(); + check(result.schema_name == "FESA_RESULTS", "schema name changed"); + check(result.schema_version == 1, "schema version changed"); + check(result.solver_name == "FESA", "solver name changed"); + check(result.dof_convention == "UX,UY,UZ,RX,RY,RZ", "DOF convention changed"); + check(result.sign_convention == "Abaqus-compatible", "sign convention changed"); + check(result.precision == "double", "precision metadata changed"); + check(result.index_type == "int64", "index metadata changed"); + check(result.node_ids.size() == domain.nodes.size(), "node model mirror changed"); + check(result.element_types == std::vector({"MITC4"}), "element type label changed"); + check(result.steps.size() == 1, "result step count changed"); + check(result.steps[0].name == "Step-Results", "step name changed"); + check(result.steps[0].frames.size() == 1, "result frame count changed"); + + const auto& frame = result.steps[0].frames[0]; + check(frame.frame_id == 0, "Phase 1 frame id changed"); + check(frame.increment == 1, "Phase 1 increment changed"); + check(frame.iteration == 0, "Phase 1 iteration changed"); + check(frame.converged, "Phase 1 convergence flag changed"); + check(frame.field_outputs.count("U") == 1, "U field missing"); + check(frame.field_outputs.count("RF") == 1, "RF field missing"); + const auto& u_field = frame.field_outputs.at("U"); + const auto& rf_field = frame.field_outputs.at("RF"); + check(u_field.position == "NODAL" && u_field.entity_type == "node" && u_field.basis == "GLOBAL", + "U field metadata changed"); + check(rf_field.position == "NODAL" && rf_field.entity_type == "node" && rf_field.basis == "GLOBAL", + "RF field metadata changed"); + check(u_field.component_labels == fesa::displacementComponentLabels(), "U component labels changed"); + check(rf_field.component_labels == fesa::reactionComponentLabels(), "RF component labels changed"); + + const auto required_columns = fesa::displacementCsvRequiredColumns(); + check(required_columns.size() == 7, "required displacement CSV column count changed"); + check(required_columns.front() == "Node Label", "CSV node label column changed"); + check(required_columns.back() == "UR-UR3", "CSV rotation column changed"); + + const auto missing_header = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2\n" + "1,0,0,0,0,0\n", + "missing-header.csv"); + check(fesa::containsDiagnostic(missing_header.diagnostics, "FESA-CSV-MISSING-COLUMN"), + "missing CSV header diagnostic changed"); + + const auto duplicate_node = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2,UR-UR3\n" + "1,0,0,0,0,0,0\n" + "1,0,0,0,0,0,0\n", + "duplicate-node.csv"); + check(fesa::containsDiagnostic(duplicate_node.diagnostics, "FESA-CSV-DUPLICATE-NODE"), + "duplicate CSV node diagnostic changed"); + + fesa::CsvDisplacementTable expected; + expected.rows[1] = {1, {0, 0, 0, 0, 0, 0}}; + expected.rows[2] = {2, {0, 0, -0.25, 0, 0, 0}}; + expected.rows[3] = {3, {0, 0, 0, 0, 0, 0}}; + expected.rows[4] = {4, {0, 0, 0, 0, 0, 0}}; + const auto comparison = fesa::compareDisplacements(u_field, expected, {1.0e-12, 1.0e-12, 1.0}); + check(comparison.pass, "displacement comparator no longer matches by node id"); + + const auto quad02 = fesa::loadDisplacementCsv(sourceRoot() + "/references/quad_02_displacements.csv"); + check(!fesa::hasError(quad02.diagnostics), "quad_02 displacement CSV no longer loads"); + check(quad02.rows.size() == 121, "quad_02 displacement CSV row count changed"); + + return 0; +}